From 39f387b8ec41087b19ed822707bae824a3c81543 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 1 Aug 2026 14:19:30 +0800 Subject: [PATCH 1/9] feat(appearance): add typed package runtime and surface contracts --- pnpm-lock.yaml | 3 + src/web-ui/package.json | 1 + src/web-ui/src/app/appearance.ts | 48 + .../app/components/AboutDialog/appearance.ts | 34 + .../AgentCompanionDesktopPet.appearance.ts | 14 + .../GalleryDetailModal.appearance.ts | 2 + .../GalleryLayout/GalleryLayout.appearance.ts | 2 + .../MCPInteractionDialog.appearance.ts | 2 + .../components/NavBar/NavBar.appearance.ts | 7 + .../NavPanel/NavSearchDialog.appearance.ts | 11 + .../src/app/components/NavPanel/appearance.ts | 46 + .../BranchQuickSwitch.appearance.ts | 12 + .../NavPanel/sections/sessions/appearance.ts | 22 + .../workspaces/WorkspaceItem.appearance.ts | 20 + .../WorkspaceListSection.appearance.ts | 9 + ...paceProjectPermissionsDialog.appearance.ts | 10 + .../WorkspaceRelatedPathsDialog.appearance.ts | 10 + .../WorkspaceSessionBatchModal.appearance.ts | 16 + .../NewProjectDialog.appearance.ts | 9 + .../RemoteConnectDisclaimer.appearance.ts | 2 + .../RemoteConnectDialog/appearance.ts | 53 + .../SceneBar/SceneBar.appearance.ts | 2 + .../SplashScreen/SplashScreen.appearance.ts | 7 + .../TitleBar/NotificationButton.appearance.ts | 2 + .../panels/BranchSelectModal.appearance.ts | 14 + .../panels/DiffFullscreenViewer.appearance.ts | 2 + .../panels/FilesPanel.appearance.ts | 21 + .../panels/TerminalEditModal.appearance.ts | 2 + .../panels/base/FlexiblePanel.appearance.ts | 32 + .../panels/base/PanelHeader.appearance.ts | 2 + .../panels/content-canvas/appearance.ts | 46 + .../editor-area/EditorArea.appearance.ts | 9 + .../editor-area/EditorGroup.appearance.ts | 7 + .../MissionControl.appearance.ts | 14 + .../ThumbnailCard.appearance.ts | 17 + .../content-canvas/tab-bar/Tab.appearance.ts | 20 + .../tab-bar/TabBar.appearance.ts | 14 + .../tab-bar/TabOverflowMenu.appearance.ts | 15 + .../panels/review-platform/appearance.ts | 64 + .../ScheduledJobsModal.appearance.ts | 2 + .../components/scheduled-jobs/appearance.ts | 33 + .../src/app/layout/AppLayout.appearance.ts | 13 + .../app/layout/FloatingMiniChat.appearance.ts | 18 + .../layout/MiniAppBubbleWelcome.appearance.ts | 10 + .../src/app/scenes/agents/appearance.ts | 13 + .../AgentCapabilityTooltip.appearance.ts | 2 + .../agents/components/AgentCard.appearance.ts | 2 + .../components/CoreAgentCard.appearance.ts | 2 + .../components/CreateAgentPage.appearance.ts | 16 + .../components/SkillGroupPicker.appearance.ts | 15 + .../components/ToolGroupPicker.appearance.ts | 13 + .../src/app/scenes/assistant/appearance.ts | 6 + .../scenes/browser/BrowserPanel.appearance.ts | 15 + .../src/app/scenes/browser/appearance.ts | 7 + .../file-viewer/FileViewerNav.appearance.ts | 2 + .../src/app/scenes/file-viewer/appearance.ts | 6 + .../src/app/scenes/git/GitNav.appearance.ts | 2 + src/web-ui/src/app/scenes/git/appearance.ts | 7 + .../git/views/BranchesView.appearance.ts | 17 + .../scenes/git/views/GraphView.appearance.ts | 7 + .../git/views/WorkingCopyView.appearance.ts | 16 + .../src/app/scenes/miniapps/appearance.ts | 21 + .../components/MiniAppCard.appearance.ts | 2 + .../MiniAppCustomizePanel.appearance.ts | 20 + .../views/MiniAppGalleryView.appearance.ts | 8 + .../views/MiniAppMarketView.appearance.ts | 11 + .../MiniAppSubmissionsView.appearance.ts | 11 + .../src/app/scenes/my-agent/appearance.ts | 7 + src/web-ui/src/app/scenes/pages/appearance.ts | 6 + .../src/app/scenes/panel-view/appearance.ts | 6 + .../src/app/scenes/profile/appearance.ts | 6 + .../profile/views/AssistantCard.appearance.ts | 15 + .../views/AssistantConfigPage.appearance.ts | 14 + .../views/AssistantDefaultsPage.appearance.ts | 18 + .../views/AssistantQuickInput.appearance.ts | 2 + .../views/NurseryGallery.appearance.ts | 8 + .../app/scenes/session/AuxPane.appearance.ts | 2 + .../session/BottomTerminalPane.appearance.ts | 2 + .../app/scenes/session/ChatPane.appearance.ts | 13 + .../src/app/scenes/session/appearance.ts | 16 + .../scenes/settings/SettingsNav.appearance.ts | 23 + .../src/app/scenes/settings/appearance.ts | 30 + .../ArchivedSessionsConfig.appearance.ts | 13 + .../KeyboardShortcutsTab.appearance.ts | 17 + .../app/scenes/shell/ShellNav.appearance.ts | 13 + src/web-ui/src/app/scenes/shell/appearance.ts | 4 + .../src/app/scenes/skills/appearance.ts | 47 + .../skills/components/SkillCard.appearance.ts | 20 + .../src/app/scenes/terminal/appearance.ts | 7 + .../src/app/scenes/welcome/appearance.ts | 23 + .../components/Alert/appearance.ts | 6 + .../components/Avatar/appearance.ts | 13 + .../components/Badge/appearance.ts | 4 + .../components/Button/appearance.ts | 34 + .../components/Card/appearance.ts | 43 + .../components/Checkbox/appearance.ts | 13 + .../components/CodeEditor/appearance.ts | 10 + .../components/ConfigPage/appearance.ts | 6 + .../components/ConfirmDialog/appearance.ts | 10 + .../components/CubeLoading/appearance.ts | 7 + .../components/CubeLogo/appearance.ts | 10 + .../components/DotMatrixLoader/appearance.ts | 7 + .../components/Empty/appearance.ts | 6 + .../components/FilterPill/appearance.ts | 10 + .../ContextCompressionCard/appearance.ts | 13 + .../components/FlowChatCards/appearance.ts | 15 + .../components/IconButton/appearance.ts | 10 + .../components/Input/appearance.ts | 33 + .../components/InputDialog/appearance.ts | 6 + .../Markdown/MermaidBlock.appearance.ts | 15 + .../components/Markdown/appearance.ts | 23 + .../components/Modal/appearance.ts | 43 + .../components/NumberInput/appearance.ts | 6 + .../components/Search/appearance.ts | 15 + .../components/Select/appearance.ts | 27 + .../components/StreamText/appearance.ts | 14 + .../components/Switch/appearance.ts | 6 + .../components/Tabs/appearance.ts | 6 + .../components/Tag/appearance.ts | 6 + .../TaskRunningIndicator/appearance.ts | 7 + .../components/TextStrokeEffect/appearance.ts | 6 + .../components/Textarea/appearance.ts | 6 + .../components/Tooltip/appearance.ts | 6 + .../components/WindowControls/appearance.ts | 10 + .../component-library/preview/appearance.ts | 58 + .../src/features/dispatch/appearance.ts | 16 + .../src/features/relay-deploy/appearance.ts | 15 + .../src/features/ssh-remote/appearance.ts | 30 + .../components/AcpPlanPanel.appearance.ts | 10 + .../components/ChatEmptyState.appearance.ts | 10 + .../components/ChatInput.appearance.ts | 71 + .../ChatInputPixelPet.appearance.ts | 14 + .../ChatInputWorkspaceStrip.appearance.ts | 13 + .../components/CodePreview.appearance.ts | 13 + .../components/CopyOutputButton.appearance.ts | 8 + .../CopyableTextPreview.appearance.ts | 15 + .../CoworkExampleCards.appearance.ts | 10 + .../CurrentSessionTitle.appearance.ts | 10 + .../DeepReviewConsentDialog.appearance.ts | 11 + .../FileMentionPicker.appearance.ts | 14 + .../components/FlowTextBlock.appearance.ts | 10 + .../ImageAnalysisCard.appearance.ts | 13 + .../InlineDiffPreview.appearance.ts | 12 + .../components/ModelSelector.appearance.ts | 21 + .../PendingQueuePanel.appearance.ts | 14 + .../components/RichTextInput.appearance.ts | 38 + .../ScrollToBottomButton.appearance.ts | 6 + .../ScrollToLatestBar.appearance.ts | 7 + .../ScrollToTurnHeaderButton.appearance.ts | 7 + .../SnapshotRollbackButton.appearance.ts | 8 + .../StickyTaskIndicator.appearance.ts | 10 + .../TaskDetailPanel.appearance.ts | 15 + .../TokenUsageIndicator.appearance.ts | 7 + .../components/ToolApprovalBar.appearance.ts | 6 + .../components/TurnHistoryPanel.appearance.ts | 11 + .../TurnRollbackButton.appearance.ts | 8 + .../components/UserMessage.appearance.ts | 13 + .../components/WelcomePanel.appearance.ts | 26 + ...BackgroundCommandInputDialog.appearance.ts | 13 + ...BackgroundCommandOutputPanel.appearance.ts | 15 + .../btw/BtwSessionPanel.appearance.ts | 26 + .../modern/ExploreGroupRenderer.appearance.ts | 9 + .../modern/ExportImageButton.appearance.ts | 12 + .../modern/FlowChatHeader.appearance.ts | 19 + .../modern/FlowChatTurnRail.appearance.ts | 18 + .../modern/ModelRoundItem.appearance.ts | 34 + .../PermissionRequestPanel.appearance.ts | 17 + .../modern/RuntimeStatusSlot.appearance.ts | 6 + .../SessionFileModificationsBar.appearance.ts | 18 + .../modern/SessionFilesBadge.appearance.ts | 29 + .../TurnCompletionNoticeItem.appearance.ts | 10 + .../TurnFailureNoticeItem.appearance.ts | 15 + .../UserMessageEditComposer.appearance.ts | 13 + .../modern/UserMessageItem.appearance.ts | 15 + .../modern/VirtualItemRenderer.appearance.ts | 10 + .../flow_chat/components/modern/appearance.ts | 38 + .../session-menu/SessionMenu.appearance.ts | 18 + .../SmartRecommendations.appearance.ts | 10 + .../SubagentProjectionView.appearance.ts | 13 + .../ThreadGoalDialogs.appearance.ts | 18 + .../toolbar-mode/ToolbarMode.appearance.ts | 24 + .../SessionRuntimeStatusEntry.appearance.ts | 6 + .../SessionUsageReportCard.appearance.ts | 14 + .../flow_chat/components/usage/appearance.ts | 24 + .../ComposerVoiceInputButton.appearance.ts | 19 + .../deep-review/action-bar/appearance.ts | 41 + .../AcpPermissionActions.appearance.ts | 11 + .../AskUserQuestionCard.appearance.ts | 15 + .../tool-cards/BaseToolCard.appearance.ts | 25 + .../tool-cards/CanvasToolCard.appearance.ts | 15 + .../CodeReviewToolCard.appearance.ts | 24 + .../tool-cards/CompactToolCard.appearance.ts | 23 + .../ComputerUseToolCard.appearance.ts | 14 + .../ContextCompressionDisplay.appearance.ts | 13 + .../CreatePlanDisplay.appearance.ts | 14 + .../tool-cards/DefaultToolCard.appearance.ts | 15 + .../ExecProcessToolCardView.appearance.ts | 14 + .../FileOperationToolCard.appearance.ts | 21 + .../GenerativeWidgetToolCard.appearance.ts | 13 + .../GetFileDiffDisplay.appearance.ts | 14 + .../tool-cards/GetToolSpecCard.appearance.ts | 13 + .../tool-cards/GitToolDisplay.appearance.ts | 9 + .../tool-cards/MCPToolDisplay.appearance.ts | 15 + .../MiniAppToolDisplay.appearance.ts | 16 + .../ModelThinkingDisplay.appearance.ts | 14 + .../ReviewSessionSummaryCard.appearance.ts | 9 + ...SnapshotFullscreenDiffViewer.appearance.ts | 11 + .../tool-cards/TaskToolDisplay.appearance.ts | 15 + .../tool-cards/TerminalToolCard.appearance.ts | 26 + .../tool-cards/TodoWriteDisplay.appearance.ts | 16 + .../ToolCardHeaderActions.appearance.ts | 6 + .../ToolCardStatusSlot.appearance.ts | 7 + .../ToolTimeoutIndicator.appearance.ts | 15 + .../ViewImageToolCard.appearance.ts | 13 + .../tool-cards/WebFetchCard.appearance.ts | 14 + .../adapters/CanvasAppearanceAdapter.ts | 51 + .../adapters/CssTokenAppearanceAdapter.ts | 62 + .../adapters/MermaidAppearanceAdapter.ts | 79 + .../adapters/MonacoAppearanceAdapter.ts | 150 ++ .../adapters/PluginAppearanceProjection.ts | 30 + .../RendererAppearanceContracts.test.ts | 54 + .../adapters/WidgetAppearanceAdapter.ts | 57 + .../adapters/XtermAppearanceAdapter.ts | 97 ++ .../adapters/widgetAppearanceVariables.ts | 60 + .../appearance/appearanceDomainTokens.ts | 155 ++ .../appearance/appearancePropertyProfiles.ts | 65 + .../appearance/builtins/AppearancePalette.ts | 235 +++ .../builtins/appearancePresetOutput.test.ts | 194 +++ .../builtins/appearancePromptSnapshots.ts | 62 + .../builtins/buildBuiltinAppearance.ts | 800 ++++++++++ .../appearance/builtins/catalog.ts | 78 + .../appearance/builtins/chinaNight.ts | 210 +++ .../appearance/builtins/chinaStyle.ts | 219 +++ .../builtins/composeAppearancePackage.test.ts | 50 + .../builtins/composeAppearancePackage.ts | 219 +++ .../appearance/builtins/cyber.ts | 205 +++ .../appearance/builtins/dark.ts | 195 +++ .../appearance/builtins/light.ts | 258 ++++ .../appearance/builtins/midnight.ts | 208 +++ .../appearance/builtins/paletteHelpers.ts | 296 ++++ .../appearance/builtins/palettes.ts | 32 + .../appearance/builtins/slate.ts | 222 +++ .../startupAppearanceBootstrap.test.ts | 75 + .../builtins/startupAppearanceBootstrap.ts | 48 + .../appearance/builtins/tokyoNight.ts | 210 +++ .../compiler/AppearanceCompiler.test.ts | 829 +++++++++++ .../appearance/compiler/AppearanceCompiler.ts | 679 +++++++++ .../appearance/hooks/useAppearance.ts | 23 + .../src/infrastructure/appearance/index.ts | 48 + .../registry/AppearanceRegistry.test.ts | 64 + .../appearance/registry/AppearanceRegistry.ts | 169 +++ .../registry/defaultAppearanceRegistry.ts | 551 +++++++ .../AppearanceBackgroundMedia.test.tsx | 24 + .../runtime/AppearanceBackgroundMedia.tsx | 63 + .../runtime/AppearanceOverlayHost.ts | 12 + .../runtime/AppearanceRuntime.test.ts | 223 +++ .../appearance/runtime/AppearanceRuntime.ts | 286 ++++ .../runtime/AppearanceService.test.ts | 476 ++++++ .../appearance/runtime/AppearanceService.ts | 689 +++++++++ .../schema/AppearancePackageParser.test.ts | 132 ++ .../schema/AppearancePackageParser.ts | 369 +++++ .../AppearancePackageValidationError.ts | 108 ++ .../schema/AppearancePackageValidator.test.ts | 415 ++++++ .../schema/AppearancePackageValidator.ts | 1303 +++++++++++++++++ .../appearance/storage/AppearanceStorage.ts | 111 ++ .../appearance/sync/AppearanceSync.ts | 82 ++ .../infrastructure/appearance/types/index.ts | 740 ++++++++++ .../src/infrastructure/config/appearance.ts | 48 + .../components/AIModelConfig.appearance.ts | 51 + .../components/AcpAgentsConfig.appearance.ts | 27 + .../components/AppearanceConfig.appearance.ts | 24 + .../components/BasicsConfig.appearance.ts | 10 + .../DefaultModelConfig.appearance.ts | 12 + .../components/EditorConfig.appearance.ts | 12 + .../ExternalMcpOverview.appearance.ts | 16 + .../ExternalSourcesConfig.appearance.ts | 37 + .../GlobalPermissionRulesDialog.appearance.ts | 9 + .../config/components/LspConfig.appearance.ts | 6 + .../MCPResourceBrowser.appearance.ts | 11 + .../components/McpToolsConfig.appearance.ts | 26 + .../QuickActionsConfig.appearance.ts | 13 + .../components/SessionConfig.appearance.ts | 37 + .../SessionTitleConfig.appearance.ts | 6 + .../components/SkillsConfig.appearance.ts | 17 + .../components/VoiceInputConfig.appearance.ts | 6 + .../VoiceInputDiagnostics.appearance.ts | 23 + .../components/WorktreesConfig.appearance.ts | 6 + .../font-preference/appearance.ts | 23 + .../src/infrastructure/i18n/appearance.ts | 23 + .../infrastructure/peer-device/appearance.ts | 35 + .../src/infrastructure/update/appearance.ts | 33 + .../shared/announcement-system/appearance.ts | 19 + .../shared/context-menu-system/appearance.ts | 19 + .../src/shared/context-system/appearance.ts | 28 + .../shared/notification-system/appearance.ts | 29 + .../src/tools/bitfun-canvas/appearance.ts | 19 + src/web-ui/src/tools/editor/appearance.ts | 21 + .../components/DiffEditor.appearance.ts | 13 + .../components/EditorBreadcrumb.appearance.ts | 15 + .../components/EditorStatusBar.appearance.ts | 8 + .../components/ImageViewer.appearance.ts | 10 + .../components/MarkdownEditor.appearance.ts | 14 + .../components/PlanViewer.appearance.ts | 15 + .../StatusBarPopovers.appearance.ts | 15 + .../meditor/components/MEditor.appearance.ts | 9 + .../components/TiptapEditor.appearance.ts | 16 + .../src/tools/file-system/appearance.ts | 15 + .../src/tools/generative-widget/appearance.ts | 22 + src/web-ui/src/tools/git/appearance.ts | 20 + .../GitBranchHistoryView.appearance.ts | 17 + .../GitDiffView/GitDiffView.appearance.ts | 18 + .../GitSettingsView.appearance.ts | 14 + src/web-ui/src/tools/lsp/appearance.ts | 17 + .../ReferencesPanel.appearance.ts | 14 + src/web-ui/src/tools/terminal/appearance.ts | 21 + src/web-ui/src/tools/workspace/appearance.ts | 15 + 316 files changed, 16174 insertions(+) create mode 100644 src/web-ui/src/app/appearance.ts create mode 100644 src/web-ui/src/app/components/AboutDialog/appearance.ts create mode 100644 src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.appearance.ts create mode 100644 src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.appearance.ts create mode 100644 src/web-ui/src/app/components/GalleryLayout/GalleryLayout.appearance.ts create mode 100644 src/web-ui/src/app/components/MCPInteractionDialog/MCPInteractionDialog.appearance.ts create mode 100644 src/web-ui/src/app/components/NavBar/NavBar.appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/NavSearchDialog.appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.appearance.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.appearance.ts create mode 100644 src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts create mode 100644 src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.appearance.ts create mode 100644 src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts create mode 100644 src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts create mode 100644 src/web-ui/src/app/components/SplashScreen/SplashScreen.appearance.ts create mode 100644 src/web-ui/src/app/components/TitleBar/NotificationButton.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/BranchSelectModal.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/DiffFullscreenViewer.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/FilesPanel.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/TerminalEditModal.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/base/FlexiblePanel.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/base/PanelHeader.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/content-canvas/appearance.ts create mode 100644 src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.appearance.ts create mode 100644 src/web-ui/src/app/components/panels/review-platform/appearance.ts create mode 100644 src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.appearance.ts create mode 100644 src/web-ui/src/app/components/scheduled-jobs/appearance.ts create mode 100644 src/web-ui/src/app/layout/AppLayout.appearance.ts create mode 100644 src/web-ui/src/app/layout/FloatingMiniChat.appearance.ts create mode 100644 src/web-ui/src/app/layout/MiniAppBubbleWelcome.appearance.ts create mode 100644 src/web-ui/src/app/scenes/agents/appearance.ts create mode 100644 src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.appearance.ts create mode 100644 src/web-ui/src/app/scenes/agents/components/AgentCard.appearance.ts create mode 100644 src/web-ui/src/app/scenes/agents/components/CoreAgentCard.appearance.ts create mode 100644 src/web-ui/src/app/scenes/agents/components/CreateAgentPage.appearance.ts create mode 100644 src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.appearance.ts create mode 100644 src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.appearance.ts create mode 100644 src/web-ui/src/app/scenes/assistant/appearance.ts create mode 100644 src/web-ui/src/app/scenes/browser/BrowserPanel.appearance.ts create mode 100644 src/web-ui/src/app/scenes/browser/appearance.ts create mode 100644 src/web-ui/src/app/scenes/file-viewer/FileViewerNav.appearance.ts create mode 100644 src/web-ui/src/app/scenes/file-viewer/appearance.ts create mode 100644 src/web-ui/src/app/scenes/git/GitNav.appearance.ts create mode 100644 src/web-ui/src/app/scenes/git/appearance.ts create mode 100644 src/web-ui/src/app/scenes/git/views/BranchesView.appearance.ts create mode 100644 src/web-ui/src/app/scenes/git/views/GraphView.appearance.ts create mode 100644 src/web-ui/src/app/scenes/git/views/WorkingCopyView.appearance.ts create mode 100644 src/web-ui/src/app/scenes/miniapps/appearance.ts create mode 100644 src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.appearance.ts create mode 100644 src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.appearance.ts create mode 100644 src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.appearance.ts create mode 100644 src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.appearance.ts create mode 100644 src/web-ui/src/app/scenes/miniapps/views/MiniAppSubmissionsView.appearance.ts create mode 100644 src/web-ui/src/app/scenes/my-agent/appearance.ts create mode 100644 src/web-ui/src/app/scenes/pages/appearance.ts create mode 100644 src/web-ui/src/app/scenes/panel-view/appearance.ts create mode 100644 src/web-ui/src/app/scenes/profile/appearance.ts create mode 100644 src/web-ui/src/app/scenes/profile/views/AssistantCard.appearance.ts create mode 100644 src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.appearance.ts create mode 100644 src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.appearance.ts create mode 100644 src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.appearance.ts create mode 100644 src/web-ui/src/app/scenes/profile/views/NurseryGallery.appearance.ts create mode 100644 src/web-ui/src/app/scenes/session/AuxPane.appearance.ts create mode 100644 src/web-ui/src/app/scenes/session/BottomTerminalPane.appearance.ts create mode 100644 src/web-ui/src/app/scenes/session/ChatPane.appearance.ts create mode 100644 src/web-ui/src/app/scenes/session/appearance.ts create mode 100644 src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts create mode 100644 src/web-ui/src/app/scenes/settings/appearance.ts create mode 100644 src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.appearance.ts create mode 100644 src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.appearance.ts create mode 100644 src/web-ui/src/app/scenes/shell/ShellNav.appearance.ts create mode 100644 src/web-ui/src/app/scenes/shell/appearance.ts create mode 100644 src/web-ui/src/app/scenes/skills/appearance.ts create mode 100644 src/web-ui/src/app/scenes/skills/components/SkillCard.appearance.ts create mode 100644 src/web-ui/src/app/scenes/terminal/appearance.ts create mode 100644 src/web-ui/src/app/scenes/welcome/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Alert/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Avatar/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Badge/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Button/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Card/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Checkbox/appearance.ts create mode 100644 src/web-ui/src/component-library/components/CodeEditor/appearance.ts create mode 100644 src/web-ui/src/component-library/components/ConfigPage/appearance.ts create mode 100644 src/web-ui/src/component-library/components/ConfirmDialog/appearance.ts create mode 100644 src/web-ui/src/component-library/components/CubeLoading/appearance.ts create mode 100644 src/web-ui/src/component-library/components/CubeLogo/appearance.ts create mode 100644 src/web-ui/src/component-library/components/DotMatrixLoader/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Empty/appearance.ts create mode 100644 src/web-ui/src/component-library/components/FilterPill/appearance.ts create mode 100644 src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/appearance.ts create mode 100644 src/web-ui/src/component-library/components/FlowChatCards/appearance.ts create mode 100644 src/web-ui/src/component-library/components/IconButton/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Input/appearance.ts create mode 100644 src/web-ui/src/component-library/components/InputDialog/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Markdown/MermaidBlock.appearance.ts create mode 100644 src/web-ui/src/component-library/components/Markdown/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Modal/appearance.ts create mode 100644 src/web-ui/src/component-library/components/NumberInput/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Search/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Select/appearance.ts create mode 100644 src/web-ui/src/component-library/components/StreamText/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Switch/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Tabs/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Tag/appearance.ts create mode 100644 src/web-ui/src/component-library/components/TaskRunningIndicator/appearance.ts create mode 100644 src/web-ui/src/component-library/components/TextStrokeEffect/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Textarea/appearance.ts create mode 100644 src/web-ui/src/component-library/components/Tooltip/appearance.ts create mode 100644 src/web-ui/src/component-library/components/WindowControls/appearance.ts create mode 100644 src/web-ui/src/component-library/preview/appearance.ts create mode 100644 src/web-ui/src/features/dispatch/appearance.ts create mode 100644 src/web-ui/src/features/relay-deploy/appearance.ts create mode 100644 src/web-ui/src/features/ssh-remote/appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/AcpPlanPanel.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ChatEmptyState.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ChatInput.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ChatInputPixelPet.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/CodePreview.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/CopyOutputButton.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/CopyableTextPreview.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/CoworkExampleCards.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/CurrentSessionTitle.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/FileMentionPicker.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/FlowTextBlock.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ImageAnalysisCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/InlineDiffPreview.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/PendingQueuePanel.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/RichTextInput.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ScrollToBottomButton.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ScrollToLatestBar.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/SnapshotRollbackButton.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/StickyTaskIndicator.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/TokenUsageIndicator.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/ToolApprovalBar.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/TurnHistoryPanel.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/TurnRollbackButton.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/UserMessage.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/WelcomePanel.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/background-command/BackgroundCommandInputDialog.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/background-command/BackgroundCommandOutputPanel.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/ExportImageButton.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatHeader.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/ModelRoundItem.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/PermissionRequestPanel.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/TurnCompletionNoticeItem.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/UserMessageItem.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/session-menu/SessionMenu.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/smart-recommendations/SmartRecommendations.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/thread-goal/ThreadGoalDialogs.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/usage/SessionRuntimeStatusEntry.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/usage/appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/voice/ComposerVoiceInputButton.appearance.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/action-bar/appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/BaseToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/CanvasToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/CompactToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ToolCardHeaderActions.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/WebFetchCard.appearance.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/CanvasAppearanceAdapter.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/CssTokenAppearanceAdapter.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/MermaidAppearanceAdapter.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/MonacoAppearanceAdapter.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/PluginAppearanceProjection.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/RendererAppearanceContracts.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/WidgetAppearanceAdapter.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/XtermAppearanceAdapter.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/widgetAppearanceVariables.ts create mode 100644 src/web-ui/src/infrastructure/appearance/appearanceDomainTokens.ts create mode 100644 src/web-ui/src/infrastructure/appearance/appearancePropertyProfiles.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/AppearancePalette.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/catalog.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/chinaNight.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/chinaStyle.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/cyber.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/dark.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/light.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/midnight.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/paletteHelpers.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/palettes.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/slate.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts create mode 100644 src/web-ui/src/infrastructure/appearance/builtins/tokyoNight.ts create mode 100644 src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.ts create mode 100644 src/web-ui/src/infrastructure/appearance/hooks/useAppearance.ts create mode 100644 src/web-ui/src/infrastructure/appearance/index.ts create mode 100644 src/web-ui/src/infrastructure/appearance/registry/AppearanceRegistry.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/registry/AppearanceRegistry.ts create mode 100644 src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceBackgroundMedia.test.tsx create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceBackgroundMedia.tsx create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.ts create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceRuntime.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceRuntime.ts create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts create mode 100644 src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.ts create mode 100644 src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidationError.ts create mode 100644 src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidator.test.ts create mode 100644 src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidator.ts create mode 100644 src/web-ui/src/infrastructure/appearance/storage/AppearanceStorage.ts create mode 100644 src/web-ui/src/infrastructure/appearance/sync/AppearanceSync.ts create mode 100644 src/web-ui/src/infrastructure/appearance/types/index.ts create mode 100644 src/web-ui/src/infrastructure/config/appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/AIModelConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/BasicsConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/DefaultModelConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/EditorConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/LspConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/McpToolsConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/QuickActionsConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/SessionConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/SessionTitleConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/SkillsConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/VoiceInputConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/VoiceInputDiagnostics.appearance.ts create mode 100644 src/web-ui/src/infrastructure/config/components/WorktreesConfig.appearance.ts create mode 100644 src/web-ui/src/infrastructure/font-preference/appearance.ts create mode 100644 src/web-ui/src/infrastructure/i18n/appearance.ts create mode 100644 src/web-ui/src/infrastructure/peer-device/appearance.ts create mode 100644 src/web-ui/src/infrastructure/update/appearance.ts create mode 100644 src/web-ui/src/shared/announcement-system/appearance.ts create mode 100644 src/web-ui/src/shared/context-menu-system/appearance.ts create mode 100644 src/web-ui/src/shared/context-system/appearance.ts create mode 100644 src/web-ui/src/shared/notification-system/appearance.ts create mode 100644 src/web-ui/src/tools/bitfun-canvas/appearance.ts create mode 100644 src/web-ui/src/tools/editor/appearance.ts create mode 100644 src/web-ui/src/tools/editor/components/DiffEditor.appearance.ts create mode 100644 src/web-ui/src/tools/editor/components/EditorBreadcrumb.appearance.ts create mode 100644 src/web-ui/src/tools/editor/components/EditorStatusBar.appearance.ts create mode 100644 src/web-ui/src/tools/editor/components/ImageViewer.appearance.ts create mode 100644 src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts create mode 100644 src/web-ui/src/tools/editor/components/PlanViewer.appearance.ts create mode 100644 src/web-ui/src/tools/editor/components/StatusBarPopovers/StatusBarPopovers.appearance.ts create mode 100644 src/web-ui/src/tools/editor/meditor/components/MEditor.appearance.ts create mode 100644 src/web-ui/src/tools/editor/meditor/components/TiptapEditor.appearance.ts create mode 100644 src/web-ui/src/tools/file-system/appearance.ts create mode 100644 src/web-ui/src/tools/generative-widget/appearance.ts create mode 100644 src/web-ui/src/tools/git/appearance.ts create mode 100644 src/web-ui/src/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.appearance.ts create mode 100644 src/web-ui/src/tools/git/components/GitDiffView/GitDiffView.appearance.ts create mode 100644 src/web-ui/src/tools/git/components/GitSettingsView/GitSettingsView.appearance.ts create mode 100644 src/web-ui/src/tools/lsp/appearance.ts create mode 100644 src/web-ui/src/tools/lsp/components/ReferencesPanel/ReferencesPanel.appearance.ts create mode 100644 src/web-ui/src/tools/terminal/appearance.ts create mode 100644 src/web-ui/src/tools/workspace/appearance.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81a9be71bd..7fb3f55214 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: immer: specifier: ^11.1.3 version: 11.1.4 + jszip: + specifier: ^3.10.1 + version: 3.10.1 katex: specifier: ^0.16.25 version: 0.16.28 diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 653f402def..5ffddab51e 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -45,6 +45,7 @@ "modern-screenshot": "^4.7.0", "i18next": "^25.8.0", "immer": "^11.1.3", + "jszip": "^3.10.1", "katex": "^0.16.25", "lucide-react": "^0.541.0", "mermaid": "^11.10.1", diff --git a/src/web-ui/src/app/appearance.ts b/src/web-ui/src/app/appearance.ts new file mode 100644 index 0000000000..910925c5f9 --- /dev/null +++ b/src/web-ui/src/app/appearance.ts @@ -0,0 +1,48 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const workbenchAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'workbench', + parts: [ + { id: 'workspace' }, + { id: 'collapsedNav' }, + { id: 'navArea' }, + { id: 'navDivider' }, + { id: 'sceneArea' }, + { id: 'viewport' }, + { id: 'viewportClip' }, + { id: 'scene' }, + { id: 'loading' }, + { id: 'empty' }, + ], + facets: [ + { + id: 'sceneId', + attribute: 'data-bf-scene-id', + values: [ + 'welcome', + 'session', + 'terminal', + 'git', + 'settings', + 'file-viewer', + 'profile', + 'agents', + 'skills', + 'miniapps', + 'pages', + 'browser', + 'assistant', + 'insights', + 'shell', + 'panel-view', + ], + }, + ], + states: [ + { id: 'fullscreen', selector: { kind: 'self', suffix: '[data-bf-state~="fullscreen"]' } }, + { id: 'toolbar', selector: { kind: 'self', suffix: '[data-bf-state~="toolbar"]' } }, + { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/AboutDialog/appearance.ts b/src/web-ui/src/app/components/AboutDialog/appearance.ts new file mode 100644 index 0000000000..10df97bf38 --- /dev/null +++ b/src/web-ui/src/app/components/AboutDialog/appearance.ts @@ -0,0 +1,34 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const aboutDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'about-dialog', + parts: [ + { id: 'root', visualRole: 'dialog', continuityGroup: 'about-dialog' }, + { id: 'hero', visualRole: 'content', continuityGroup: 'about-dialog' }, + { id: 'title', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'version', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'decoration', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'content', visualRole: 'content', continuityGroup: 'about-dialog' }, + { id: 'updateCard', visualRole: 'card' }, + { id: 'updateHeader', visualRole: 'toolbar' }, + { id: 'updateFeedback', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'updateActions', visualRole: 'toolbar' }, + { id: 'progress', propertyProfile: 'control', visualRole: 'control' }, + { id: 'progressFill', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'infoCard', visualRole: 'card' }, + { id: 'infoRow', visualRole: 'content' }, + { id: 'infoLabel', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'infoValue', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'copyButton', propertyProfile: 'control', visualRole: 'control' }, + { id: 'footer', visualRole: 'toolbar', continuityGroup: 'about-dialog' }, + { id: 'license', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'copyright', propertyProfile: 'paint', visualRole: 'content' }, + ], + states: [ + { id: 'checking', selector: { kind: 'ancestorPart', part: 'updateCard', suffix: '[data-bf-state~="checking"]' } }, + { id: 'latest', selector: { kind: 'ancestorPart', part: 'updateCard', suffix: '[data-bf-state~="latest"]' } }, + { id: 'downloading', selector: { kind: 'ancestorPart', part: 'updateCard', suffix: '[data-bf-state~="downloading"]' } }, + { id: 'installed', selector: { kind: 'ancestorPart', part: 'updateCard', suffix: '[data-bf-state~="installed"]' } }, + { id: 'error', selector: { kind: 'ancestorPart', part: 'updateCard', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.appearance.ts b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.appearance.ts new file mode 100644 index 0000000000..1f6c7bf437 --- /dev/null +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const agentCompanionDesktopPetAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'agent-companion-desktop-pet', + parts: [ + { id: 'root' }, { id: 'dock' }, { id: 'bubbles' }, { id: 'bubble' }, + { id: 'bubbleTitle' }, { id: 'bubbleStatus' }, { id: 'bubbleOutput' }, + { id: 'hitbox' }, { id: 'pet' }, + ], + states: [ + { id: 'attention', selector: { kind: 'self', suffix: '[data-bf-state~="attention"]' } }, + { id: 'typing', selector: { kind: 'self', suffix: '[data-bf-state~="typing"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.appearance.ts b/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.appearance.ts new file mode 100644 index 0000000000..888b52c4b6 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const galleryDetailModalAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'gallery-detail-modal', parts: [{ id: 'root' }, { id: 'hero' }, { id: 'icon' }, { id: 'summary' }, { id: 'badges' }, { id: 'description' }, { id: 'meta' }, { id: 'content' }, { id: 'actions' }] }; diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.appearance.ts b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.appearance.ts new file mode 100644 index 0000000000..7871753d92 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const galleryLayoutAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'gallery-layout', parts: [{ id: 'root' }, { id: 'body' }, { id: 'content' }] }; diff --git a/src/web-ui/src/app/components/MCPInteractionDialog/MCPInteractionDialog.appearance.ts b/src/web-ui/src/app/components/MCPInteractionDialog/MCPInteractionDialog.appearance.ts new file mode 100644 index 0000000000..8809defbd1 --- /dev/null +++ b/src/web-ui/src/app/components/MCPInteractionDialog/MCPInteractionDialog.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const mcpInteractionDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'mcp-interaction-dialog', parts: [{ id: 'root' }, { id: 'meta' }, { id: 'section' }, { id: 'label' }, { id: 'params' }, { id: 'editor' }, { id: 'actions' }] }; diff --git a/src/web-ui/src/app/components/NavBar/NavBar.appearance.ts b/src/web-ui/src/app/components/NavBar/NavBar.appearance.ts new file mode 100644 index 0000000000..e3cb2ec584 --- /dev/null +++ b/src/web-ui/src/app/components/NavBar/NavBar.appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const navBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'nav-bar', + parts: [{ id: 'root' }, { id: 'panelToggle' }, { id: 'back' }, { id: 'forward' }], + states: [{ id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }], +}; diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.appearance.ts b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.appearance.ts new file mode 100644 index 0000000000..683c6789f5 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.appearance.ts @@ -0,0 +1,11 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const navSearchDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'nav-search-dialog', + parts: [ + { id: 'root' }, { id: 'card' }, { id: 'inputRow' }, { id: 'search' }, + { id: 'results' }, { id: 'empty' }, { id: 'group' }, { id: 'groupLabel' }, + { id: 'item' }, { id: 'itemIcon' }, { id: 'itemContent' }, { id: 'itemLabel' }, + { id: 'itemSublabel' }, { id: 'sessionHint' }, + ], + states: [{ id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }], +}; diff --git a/src/web-ui/src/app/components/NavPanel/appearance.ts b/src/web-ui/src/app/components/NavPanel/appearance.ts new file mode 100644 index 0000000000..d556833cac --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/appearance.ts @@ -0,0 +1,46 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const navPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'nav-panel', + parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'panel', continuityGroup: 'nav-panel' }, + { id: 'content', visualRole: 'continuous-surface', continuityGroup: 'nav-panel' }, + { id: 'mainLayer', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'nav-panel' }, + { id: 'sceneLayer', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'nav-panel' }, + { id: 'sceneContent', visualRole: 'content', continuityGroup: 'nav-panel' }, + { id: 'brandHeader', visualRole: 'toolbar', continuityGroup: 'nav-panel' }, + { id: 'search', propertyProfile: 'control', visualRole: 'control' }, + { id: 'searchTrigger', propertyProfile: 'control', visualRole: 'control' }, + { id: 'topActions', visualRole: 'toolbar' }, + { id: 'topAction', propertyProfile: 'control', visualRole: 'control' }, + { id: 'extensionGroup', visualRole: 'content' }, + { id: 'sections', visualRole: 'content', continuityGroup: 'nav-panel' }, + { id: 'section', visualRole: 'content' }, + { id: 'sectionContent', visualRole: 'content' }, + { id: 'bottomBar', visualRole: 'toolbar', continuityGroup: 'nav-panel' }, + { id: 'miniAppFooter', visualRole: 'toolbar', continuityGroup: 'nav-panel' }, + { id: 'footer', visualRole: 'toolbar', continuityGroup: 'nav-panel' }, + { id: 'footerButton', propertyProfile: 'control', visualRole: 'control' }, + { id: 'footerMenu', propertyProfile: 'overlay', visualRole: 'popup' }, + { id: 'footerMenuItem', propertyProfile: 'control', visualRole: 'control' }, + { id: 'footerMenuDivider', propertyProfile: 'paint', visualRole: 'divider' }, + { id: 'workspaceMenu', propertyProfile: 'overlay', visualRole: 'popup' }, + { id: 'workspaceMenuItem', propertyProfile: 'control', visualRole: 'control' }, + { id: 'workspaceMenuDivider', propertyProfile: 'paint', visualRole: 'divider' }, + { id: 'workspaceMenuTitle', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'workspaceMenuEmpty', visualRole: 'content' }, + ], + facets: [ + { id: 'layer', attribute: 'data-bf-layer', values: ['main', 'scene'] }, + { id: 'action', attribute: 'data-bf-action', values: ['code', 'cowork', 'assistant', 'extensions', 'agents', 'skills'] }, + { id: 'section', attribute: 'data-bf-section', values: ['assistant-sessions', 'workspace'] }, + ], + states: [ + { id: 'scene', selector: { kind: 'self', suffix: '[data-bf-state~="scene"]' } }, + { id: 'split', selector: { kind: 'self', suffix: '[data-bf-state~="split"]' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'closing', selector: { kind: 'self', suffix: '[data-bf-state~="closing"]' } }, + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.appearance.ts b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.appearance.ts new file mode 100644 index 0000000000..53f5a93eab --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.appearance.ts @@ -0,0 +1,12 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const branchQuickSwitchAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'branch-quick-switch', + parts: [ + { id: 'root' }, { id: 'search' }, { id: 'input' }, { id: 'list' }, + { id: 'loading' }, { id: 'empty' }, { id: 'item' }, { id: 'itemName' }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'current', selector: { kind: 'self', suffix: '[data-bf-state~="current"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/appearance.ts new file mode 100644 index 0000000000..e5195ef92c --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/appearance.ts @@ -0,0 +1,22 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionsSectionAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'sessions-section', + parts: [ + { id: 'root' }, + { id: 'loading' }, + { id: 'retry' }, + { id: 'row' }, + { id: 'rowMain' }, + { id: 'edit' }, + { id: 'actions' }, + { id: 'menu' }, + { id: 'toggle' }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'editing', selector: { kind: 'self', suffix: '[data-bf-state~="editing"]' } }, + { id: 'menuOpen', selector: { kind: 'self', suffix: '[data-bf-state~="menuOpen"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance.ts new file mode 100644 index 0000000000..1fbc2b7d7d --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance.ts @@ -0,0 +1,20 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const workspaceItemAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'workspace-item', + parts: [ + { id: 'root' }, { id: 'card' }, { id: 'collapse' }, { id: 'icon' }, + { id: 'name' }, { id: 'label' }, { id: 'badge' }, { id: 'action' }, + { id: 'menu' }, { id: 'menuTrigger' }, { id: 'menuPopover' }, { id: 'menuItem' }, + { id: 'menuDivider' }, { id: 'sessions' }, { id: 'remoteStatus' }, + { id: 'indexIndicator' }, { id: 'indexPanel' }, + ], + facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['workspace', 'assistant'] }], + states: [ + { id: 'active', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="active"]' } }, + { id: 'dragging', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="dragging"]' } }, + { id: 'collapsed', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="collapsed"]' } }, + { id: 'remote', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="remote"]' } }, + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.appearance.ts new file mode 100644 index 0000000000..dc5da798c8 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const workspaceListSectionAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'workspace-list-section', + parts: [{ id: 'root' }, { id: 'empty' }, { id: 'item' }, { id: 'dropLine' }], + states: [ + { id: 'dragging', selector: { kind: 'self', suffix: '[data-bf-state~="dragging"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance.ts new file mode 100644 index 0000000000..4ed7f5c211 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const workspaceProjectPermissionsDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'workspace-project-permissions-dialog', + parts: [ + { id: 'root' }, { id: 'intro' }, { id: 'section' }, { id: 'sectionHeader' }, + { id: 'grants' }, { id: 'grant' }, { id: 'rules' }, { id: 'rule' }, + { id: 'ruleActions' }, { id: 'empty' }, { id: 'footer' }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.appearance.ts new file mode 100644 index 0000000000..ee737b265d --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const workspaceRelatedPathsDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'workspace-related-paths-dialog', + parts: [ + { id: 'root' }, { id: 'intro' }, { id: 'introCopy' }, { id: 'scope' }, + { id: 'empty' }, { id: 'list' }, { id: 'card' }, { id: 'cardHeader' }, + { id: 'pathRow' }, { id: 'description' }, { id: 'error' }, { id: 'footer' }, + { id: 'footerActions' }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.appearance.ts new file mode 100644 index 0000000000..d05460f47f --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const workspaceSessionBatchModalAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'workspace-session-batch-modal', + parts: [ + { id: 'root' }, { id: 'hero' }, { id: 'toolbar' }, { id: 'toolbarActions' }, + { id: 'summary' }, { id: 'list' }, { id: 'state' }, { id: 'row' }, + { id: 'rowCheck' }, { id: 'rowContent' }, { id: 'footer' }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'child', selector: { kind: 'self', suffix: '[data-bf-state~="child"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts new file mode 100644 index 0000000000..d6e526036e --- /dev/null +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const newProjectDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'new-project-dialog', + parts: [ + { id: 'root' }, { id: 'hero' }, { id: 'title' }, { id: 'content' }, + { id: 'field' }, { id: 'pathSelector' }, { id: 'preview' }, + { id: 'error' }, { id: 'footer' }, + ], +}; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.appearance.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.appearance.ts new file mode 100644 index 0000000000..ceaca47817 --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const remoteConnectDisclaimerAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'remote-connect-disclaimer', parts: [{ id: 'root' }, { id: 'meta' }, { id: 'intro' }, { id: 'title' }, { id: 'riskList' }, { id: 'details' }, { id: 'actions' }] }; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts b/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts new file mode 100644 index 0000000000..e2aeb50959 --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts @@ -0,0 +1,53 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const remoteConnectDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'remote-connect-dialog', + parts: [ + { id: 'root' }, + { id: 'groups' }, + { id: 'groupTab' }, + { id: 'subtabs' }, + { id: 'subtab' }, + { id: 'panel' }, + { id: 'body' }, + { id: 'status' }, + { id: 'error' }, + ], + facets: [ + { id: 'group', attribute: 'data-bf-group', values: ['network', 'bot', 'account'] }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'connected', selector: { kind: 'self', suffix: '[data-bf-state~="connected"]' } }, + ], +}; + +export const remoteAccountPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'remote-account-panel', + parts: [ + { id: 'root' }, + { id: 'error' }, + { id: 'loading' }, + { id: 'scroll' }, + { id: 'form' }, + { id: 'actions' }, + { id: 'syncOptions' }, + { id: 'syncOption' }, + { id: 'server' }, + { id: 'syncStatus' }, + { id: 'progressTrack' }, + { id: 'progressFill' }, + { id: 'deviceList' }, + { id: 'deviceCard' }, + { id: 'pages' }, + { id: 'pagesEntry' }, + ], + facets: [ + { id: 'view', attribute: 'data-bf-view', values: ['login', 'overwrite', 'devices'] }, + ], + states: [ + { id: 'offline', selector: { kind: 'self', suffix: '[data-bf-state~="offline"]' } }, + { id: 'current', selector: { kind: 'self', suffix: '[data-bf-state~="current"]' } }, + { id: 'syncing', selector: { kind: 'self', suffix: '[data-bf-state~="syncing"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts b/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts new file mode 100644 index 0000000000..9b6d27c3ab --- /dev/null +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const sceneBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'scene-bar', parts: [{ id: 'root' }, { id: 'tabs' }, { id: 'controls' }] }; diff --git a/src/web-ui/src/app/components/SplashScreen/SplashScreen.appearance.ts b/src/web-ui/src/app/components/SplashScreen/SplashScreen.appearance.ts new file mode 100644 index 0000000000..ac89e0de2e --- /dev/null +++ b/src/web-ui/src/app/components/SplashScreen/SplashScreen.appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const splashScreenAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'splash-screen', + parts: [{ id: 'root' }, { id: 'center' }, { id: 'logo' }, { id: 'message' }], + states: [{ id: 'exiting', selector: { kind: 'self', suffix: '[data-bf-state~="exiting"]' } }], +}; diff --git a/src/web-ui/src/app/components/TitleBar/NotificationButton.appearance.ts b/src/web-ui/src/app/components/TitleBar/NotificationButton.appearance.ts new file mode 100644 index 0000000000..b3a6a227c8 --- /dev/null +++ b/src/web-ui/src/app/components/TitleBar/NotificationButton.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const notificationButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'notification-button', parts: [{ id: 'root' }, { id: 'progress' }, { id: 'loadingIcon' }, { id: 'progressIcon' }, { id: 'progressText' }, { id: 'tooltip' }, { id: 'tooltipContent' }] }; diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.appearance.ts b/src/web-ui/src/app/components/panels/BranchSelectModal.appearance.ts new file mode 100644 index 0000000000..34326d9ea8 --- /dev/null +++ b/src/web-ui/src/app/components/panels/BranchSelectModal.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const branchSelectModalAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'branch-select-modal', + parts: [ + { id: 'overlay' }, { id: 'root' }, { id: 'close' }, { id: 'header' }, + { id: 'content' }, { id: 'input' }, { id: 'error' }, { id: 'list' }, + { id: 'loading' }, { id: 'item' }, { id: 'itemName' }, { id: 'badge' }, + { id: 'empty' }, { id: 'footer' }, { id: 'options' }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'current', selector: { kind: 'self', suffix: '[data-bf-state~="current"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/panels/DiffFullscreenViewer.appearance.ts b/src/web-ui/src/app/components/panels/DiffFullscreenViewer.appearance.ts new file mode 100644 index 0000000000..852ae492e1 --- /dev/null +++ b/src/web-ui/src/app/components/panels/DiffFullscreenViewer.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const diffFullscreenViewerAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'diff-fullscreen-viewer', parts: [{ id: 'overlay' }, { id: 'container' }, { id: 'header' }, { id: 'fileInfo' }, { id: 'actions' }, { id: 'content' }, { id: 'loading' }] }; diff --git a/src/web-ui/src/app/components/panels/FilesPanel.appearance.ts b/src/web-ui/src/app/components/panels/FilesPanel.appearance.ts new file mode 100644 index 0000000000..05e2bd86b9 --- /dev/null +++ b/src/web-ui/src/app/components/panels/FilesPanel.appearance.ts @@ -0,0 +1,21 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const filesPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'files-panel', + parts: [ + { id: 'root' }, + { id: 'content' }, + { id: 'search' }, + { id: 'searchToolbar' }, + { id: 'main' }, + { id: 'placeholder' }, + { id: 'error' }, + { id: 'transfers' }, + { id: 'transfer' }, + { id: 'transferTrack' }, + { id: 'transferFill' }, + ], + facets: [ + { id: 'searchMode', attribute: 'data-bf-search-mode', values: ['content', 'filenames'] }, + ], +}; diff --git a/src/web-ui/src/app/components/panels/TerminalEditModal.appearance.ts b/src/web-ui/src/app/components/panels/TerminalEditModal.appearance.ts new file mode 100644 index 0000000000..3d9f4f300f --- /dev/null +++ b/src/web-ui/src/app/components/panels/TerminalEditModal.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const terminalEditModalAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'terminal-edit-modal', parts: [{ id: 'content' }, { id: 'footer' }] }; diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.appearance.ts b/src/web-ui/src/app/components/panels/base/FlexiblePanel.appearance.ts new file mode 100644 index 0000000000..12116e5adf --- /dev/null +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.appearance.ts @@ -0,0 +1,32 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const flexiblePanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'flexible-panel', + parts: [ + { id: 'root' }, + { id: 'header' }, + { id: 'headerMain' }, + { id: 'headerActions' }, + { id: 'content' }, + { id: 'loading' }, + { id: 'empty' }, + { id: 'code' }, + { id: 'markdown' }, + { id: 'markdownEditor' }, + { id: 'text' }, + { id: 'viewer' }, + { id: 'image' }, + { id: 'error' }, + { id: 'aiSession' }, + { id: 'sessionHeader' }, + { id: 'sessionDetails' }, + { id: 'operations' }, + { id: 'operation' }, + { id: 'terminal' }, + { id: 'unknown' }, + ], + states: [ + { id: 'needsFix', selector: { kind: 'self', suffix: '[data-bf-state~="needsFix"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/panels/base/PanelHeader.appearance.ts b/src/web-ui/src/app/components/panels/base/PanelHeader.appearance.ts new file mode 100644 index 0000000000..553e74623c --- /dev/null +++ b/src/web-ui/src/app/components/panels/base/PanelHeader.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const panelHeaderAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'panel-header', parts: [{ id: 'root' }, { id: 'title' }, { id: 'actions' }] }; diff --git a/src/web-ui/src/app/components/panels/content-canvas/appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/appearance.ts new file mode 100644 index 0000000000..05214ed0d3 --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/appearance.ts @@ -0,0 +1,46 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const contentCanvasAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'content-canvas', + parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'workspace', continuityGroup: 'content-canvas' }, + { id: 'main', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'content-canvas' }, + { id: 'editor', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'content-canvas' }, + { id: 'anchorContent', visualRole: 'content', continuityGroup: 'content-canvas-anchor' }, + { id: 'anchor', propertyProfile: 'layout', visualRole: 'panel' }, + { id: 'anchorResizer' }, + { id: 'anchorHeader', visualRole: 'toolbar', continuityGroup: 'content-canvas-anchor' }, + { id: 'anchorTitle', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'anchorActions', visualRole: 'toolbar' }, + { id: 'splitHandle', propertyProfile: 'control', visualRole: 'divider' }, + { id: 'dropZone', propertyProfile: 'overlay', visualRole: 'decoration' }, + { id: 'dropContent', visualRole: 'content' }, + { id: 'dropOverlay', propertyProfile: 'overlay', visualRole: 'decoration' }, + { id: 'dropTarget', propertyProfile: 'overlay', visualRole: 'decoration' }, + { id: 'dropIndicator', propertyProfile: 'overlay', visualRole: 'divider' }, + { id: 'empty', visualRole: 'content' }, + { id: 'emptyToolbar', visualRole: 'toolbar' }, + { id: 'emptyContent', visualRole: 'content' }, + { id: 'quickLook', propertyProfile: 'overlay', visualRole: 'popup' }, + { id: 'quickLookHeader', visualRole: 'toolbar', continuityGroup: 'content-canvas-quick-look' }, + { id: 'quickLookTitle', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'quickLookActions', visualRole: 'toolbar' }, + { id: 'quickLookContent', visualRole: 'content', continuityGroup: 'content-canvas-quick-look' }, + { id: 'quickLookFooter', visualRole: 'toolbar', continuityGroup: 'content-canvas-quick-look' }, + { id: 'searchFilter', propertyProfile: 'control', visualRole: 'control' }, + ], + facets: [ + { id: 'mode', attribute: 'data-bf-mode', values: ['agent', 'project', 'git', 'bottom-terminal'] }, + { id: 'direction', attribute: 'data-bf-direction', values: ['horizontal', 'vertical'] }, + { id: 'position', attribute: 'data-bf-position', values: ['left', 'right', 'top', 'bottom', 'center'] }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'maximized', selector: { kind: 'self', suffix: '[data-bf-state~="maximized"]' } }, + { id: 'resizing', selector: { kind: 'self', suffix: '[data-bf-state~="resizing"]' } }, + { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, + { id: 'dragging', selector: { kind: 'self', suffix: '[data-bf-state~="dragging"]' } }, + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.appearance.ts new file mode 100644 index 0000000000..9adb07be1f --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const canvasEditorAreaAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'canvas-editor-area', + parts: [ + { id: 'root' }, { id: 'primary' }, { id: 'secondary' }, + { id: 'tertiary' }, { id: 'topRow' }, + ], + facets: [{ id: 'layout', attribute: 'data-bf-layout', values: ['none', 'horizontal', 'vertical', 'grid'] }], +}; diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.appearance.ts new file mode 100644 index 0000000000..3de1ed45e2 --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const canvasEditorGroupAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'canvas-editor-group', + parts: [{ id: 'root' }, { id: 'content' }, { id: 'tabContent' }, { id: 'empty' }, { id: 'emptyContent' }], + facets: [{ id: 'group', attribute: 'data-bf-group', values: ['primary', 'secondary', 'tertiary'] }], + states: [{ id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }], +}; diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.appearance.ts new file mode 100644 index 0000000000..78d56c2a29 --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const missionControlAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'mission-control', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'header' }, { id: 'headerActions' }, + { id: 'filters' }, { id: 'search' }, { id: 'groupFilters' }, { id: 'filter' }, + { id: 'grid' }, { id: 'empty' }, { id: 'footer' }, { id: 'separator' }, + ], + facets: [{ id: 'group', attribute: 'data-bf-group', values: ['primary', 'secondary', 'tertiary'] }], + states: [ + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.appearance.ts new file mode 100644 index 0000000000..97b470be55 --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.appearance.ts @@ -0,0 +1,17 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const canvasThumbnailAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'canvas-thumbnail', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'icon' }, { id: 'title' }, + { id: 'actions' }, { id: 'action' }, { id: 'preview' }, { id: 'code' }, + { id: 'placeholder' }, { id: 'groupBadge' }, + ], + facets: [{ id: 'group', attribute: 'data-bf-group', values: ['primary', 'secondary', 'tertiary'] }], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'dirty', selector: { kind: 'self', suffix: '[data-bf-state~="dirty"]' } }, + { id: 'deleted', selector: { kind: 'self', suffix: '[data-bf-state~="deleted"]' } }, + { id: 'pinned', selector: { kind: 'self', suffix: '[data-bf-state~="pinned"]' } }, + { id: 'preview', selector: { kind: 'self', suffix: '[data-bf-state~="preview"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.appearance.ts new file mode 100644 index 0000000000..beaa9379d1 --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.appearance.ts @@ -0,0 +1,20 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const canvasTabAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'canvas-tab', + parts: [ + { id: 'root', propertyProfile: 'control', visualRole: 'continuous-surface', continuityGroup: 'canvas-tabs' }, + { id: 'typeIcon', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'title', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'dirtyIndicator', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'action', propertyProfile: 'control', visualRole: 'control' }, + ], + facets: [{ id: 'group', attribute: 'data-bf-group', values: ['primary', 'secondary', 'tertiary'] }], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'dirty', selector: { kind: 'self', suffix: '[data-bf-state~="dirty"]' } }, + { id: 'dragging', selector: { kind: 'self', suffix: '[data-bf-state~="dragging"]' } }, + { id: 'deleted', selector: { kind: 'self', suffix: '[data-bf-state~="deleted"]' } }, + { id: 'pinned', selector: { kind: 'self', suffix: '[data-bf-state~="pinned"]' } }, + { id: 'preview', selector: { kind: 'self', suffix: '[data-bf-state~="preview"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.appearance.ts new file mode 100644 index 0000000000..7f90f3decc --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const canvasTabBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'canvas-tab-bar', + parts: [ + { id: 'root', visualRole: 'toolbar', continuityGroup: 'canvas-tabs' }, + { id: 'list', visualRole: 'continuous-surface', continuityGroup: 'canvas-tabs' }, + { id: 'tabWrapper', visualRole: 'continuous-surface', continuityGroup: 'canvas-tabs' }, + { id: 'dropIndicator', propertyProfile: 'overlay', visualRole: 'divider' }, + { id: 'actions', visualRole: 'toolbar' }, + { id: 'action', propertyProfile: 'control', visualRole: 'control' }, + ], + facets: [{ id: 'group', attribute: 'data-bf-group', values: ['primary', 'secondary', 'tertiary'] }], + states: [{ id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }], +}; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.appearance.ts new file mode 100644 index 0000000000..ea68e98603 --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const canvasTabOverflowAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'canvas-tab-overflow', + parts: [ + { id: 'root' }, { id: 'trigger' }, { id: 'badge' }, { id: 'menu' }, + { id: 'missionControl' }, { id: 'divider' }, { id: 'list' }, + { id: 'item' }, { id: 'itemTitle' }, { id: 'itemClose' }, + ], + states: [ + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'dirty', selector: { kind: 'self', suffix: '[data-bf-state~="dirty"]' } }, + { id: 'deleted', selector: { kind: 'self', suffix: '[data-bf-state~="deleted"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/panels/review-platform/appearance.ts b/src/web-ui/src/app/components/panels/review-platform/appearance.ts new file mode 100644 index 0000000000..d1413876cc --- /dev/null +++ b/src/web-ui/src/app/components/panels/review-platform/appearance.ts @@ -0,0 +1,64 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const reviewPlatformAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'review-platform', + parts: [ + { id: 'root' }, + { id: 'chrome' }, + { id: 'brand' }, + { id: 'title' }, + { id: 'subtitle' }, + { id: 'actions' }, + { id: 'statusBar' }, + { id: 'statusLine' }, + { id: 'body' }, + { id: 'listPane' }, + { id: 'listToolbar' }, + { id: 'filters' }, + { id: 'listScroll' }, + { id: 'listItem' }, + { id: 'listItemMain' }, + { id: 'listItemTitle' }, + { id: 'listItemMeta' }, + { id: 'listItemStats' }, + { id: 'pagination' }, + { id: 'emptyState' }, + { id: 'errorState' }, + { id: 'loadingState' }, + { id: 'authGate' }, + { id: 'authCopy' }, + { id: 'authActions' }, + { id: 'detailPane' }, + { id: 'detailHeader' }, + { id: 'detailTitle' }, + { id: 'detailState' }, + { id: 'detailMeta' }, + { id: 'detailActions' }, + { id: 'facts' }, + { id: 'tabContent' }, + { id: 'section' }, + { id: 'sectionHeading' }, + { id: 'sectionActions' }, + { id: 'ciItem' }, + { id: 'ciHead' }, + { id: 'ciStatus' }, + { id: 'ciLog' }, + { id: 'thread' }, + { id: 'threadHead' }, + { id: 'threadBody' }, + { id: 'fileList' }, + { id: 'fileCard' }, + { id: 'fileRow' }, + { id: 'fileMain' }, + { id: 'fileStatus' }, + { id: 'filePath' }, + { id: 'fileDelta' }, + { id: 'diff' }, + ], + facets: [ + { id: 'layout', attribute: 'data-bf-layout', values: ['full', 'detail'] }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.appearance.ts b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.appearance.ts new file mode 100644 index 0000000000..3f47c3df9d --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const scheduledJobsModalAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'scheduled-jobs-modal', parts: [{ id: 'body' }] }; diff --git a/src/web-ui/src/app/components/scheduled-jobs/appearance.ts b/src/web-ui/src/app/components/scheduled-jobs/appearance.ts new file mode 100644 index 0000000000..635c4e9e3f --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/appearance.ts @@ -0,0 +1,33 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const scheduledJobsViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'scheduled-jobs-view', + parts: [ + { id: 'root' }, + { id: 'header' }, + { id: 'target' }, + { id: 'empty' }, + { id: 'list' }, + { id: 'job' }, + { id: 'jobBody' }, + { id: 'jobActions' }, + { id: 'jobMeta' }, + { id: 'error' }, + { id: 'editor' }, + { id: 'form' }, + { id: 'warning' }, + { id: 'field' }, + { id: 'fieldMeta' }, + { id: 'fieldControl' }, + { id: 'toggle' }, + { id: 'formActions' }, + ], + facets: [ + { id: 'target', attribute: 'data-bf-target', values: ['session', 'workspace'] }, + { id: 'schedule', attribute: 'data-bf-schedule', values: ['at', 'every', 'cron'] }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/app/layout/AppLayout.appearance.ts b/src/web-ui/src/app/layout/AppLayout.appearance.ts new file mode 100644 index 0000000000..ea801e211c --- /dev/null +++ b/src/web-ui/src/app/layout/AppLayout.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const appLayoutAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'app-layout', + parts: [ + { id: 'root' }, { id: 'main' }, { id: 'windowModeHint' }, + { id: 'windowModeTitle' }, { id: 'windowModeDetail' }, + ], + states: [ + { id: 'fullscreen', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="fullscreen"]' } }, + { id: 'toolbar', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="toolbar"]' } }, + ], +}; diff --git a/src/web-ui/src/app/layout/FloatingMiniChat.appearance.ts b/src/web-ui/src/app/layout/FloatingMiniChat.appearance.ts new file mode 100644 index 0000000000..cfe302822f --- /dev/null +++ b/src/web-ui/src/app/layout/FloatingMiniChat.appearance.ts @@ -0,0 +1,18 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const floatingMiniChatAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'floating-mini-chat', + parts: [ + { id: 'root' }, { id: 'backdrop' }, { id: 'trigger' }, { id: 'triggerActivity' }, + { id: 'triggerIcon' }, { id: 'panel' }, { id: 'header' }, { id: 'sessionIcon' }, + { id: 'headerAction' }, { id: 'title' }, { id: 'body' }, { id: 'pending' }, + { id: 'pendingIcon' }, + ], + facets: [ + { id: 'mode', attribute: 'data-bf-mode', values: ['chat', 'miniapp'] }, + ], + states: [ + { id: 'open', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="open"]' } }, + { id: 'processing', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="processing"]' } }, + { id: 'customizing', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="customizing"]' } }, + ], +}; diff --git a/src/web-ui/src/app/layout/MiniAppBubbleWelcome.appearance.ts b/src/web-ui/src/app/layout/MiniAppBubbleWelcome.appearance.ts new file mode 100644 index 0000000000..4b17201e6d --- /dev/null +++ b/src/web-ui/src/app/layout/MiniAppBubbleWelcome.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const miniAppBubbleWelcomeAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'miniapp-bubble-welcome', + parts: [ + { id: 'root' }, { id: 'icon' }, { id: 'eyebrow' }, { id: 'title' }, + { id: 'description' }, { id: 'workspace' }, { id: 'suggestions' }, + { id: 'suggestionsLabel' }, { id: 'suggestionsList' }, { id: 'suggestion' }, + ], +}; diff --git a/src/web-ui/src/app/scenes/agents/appearance.ts b/src/web-ui/src/app/scenes/agents/appearance.ts new file mode 100644 index 0000000000..a445af9ea7 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const agentsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'agents', + parts: [ + { id: 'root' }, + { id: 'anchorBar' }, + { id: 'zones' }, + { id: 'coreGrid' }, + { id: 'filters' }, + { id: 'detailSection' }, + ], +}; diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.appearance.ts b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.appearance.ts new file mode 100644 index 0000000000..073f7a65c1 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const agentCapabilityTooltipAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'agent-capability-tooltip', parts: [{ id: 'body' }, { id: 'title' }, { id: 'description' }, { id: 'fields' }, { id: 'field' }] }; diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCard.appearance.ts b/src/web-ui/src/app/scenes/agents/components/AgentCard.appearance.ts new file mode 100644 index 0000000000..b1172568b9 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCard.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const agentCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'agent-card', parts: [{ id: 'root' }, { id: 'header' }, { id: 'iconArea' }, { id: 'icon' }, { id: 'headerInfo' }, { id: 'titleRow' }, { id: 'name' }, { id: 'badges' }, { id: 'body' }, { id: 'description' }, { id: 'footer' }, { id: 'capabilities' }, { id: 'meta' }] }; diff --git a/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.appearance.ts b/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.appearance.ts new file mode 100644 index 0000000000..09493ccfa4 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const coreAgentCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'core-agent-card', parts: [{ id: 'root' }, { id: 'header' }, { id: 'icon' }, { id: 'headerInfo' }, { id: 'name' }, { id: 'role' }, { id: 'body' }, { id: 'description' }, { id: 'footer' }, { id: 'tags' }, { id: 'meta' }] }; diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.appearance.ts b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.appearance.ts new file mode 100644 index 0000000000..af77e47497 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const createAgentPageAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'create-agent-page', + parts: [ + { id: 'root' }, { id: 'editorBar' }, { id: 'back' }, { id: 'body' }, + { id: 'heading' }, { id: 'actions' }, { id: 'form' }, { id: 'columns' }, + { id: 'column' }, { id: 'field' }, { id: 'levelGroup' }, { id: 'levelOption' }, + { id: 'error' }, { id: 'tools' }, { id: 'tool' }, { id: 'prompt' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.appearance.ts b/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.appearance.ts new file mode 100644 index 0000000000..d9215e768e --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const skillGroupPickerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'skill-group-picker', + parts: [ + { id: 'root' }, { id: 'head' }, { id: 'sections' }, { id: 'section' }, + { id: 'group' }, { id: 'groupHeader' }, { id: 'groupActions' }, + { id: 'tokenGrid' }, { id: 'token' }, { id: 'manager' }, + { id: 'managerEditor' }, { id: 'managerList' }, { id: 'managerGroup' }, + { id: 'summary' }, { id: 'summaryGroup' }, { id: 'empty' }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.appearance.ts b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.appearance.ts new file mode 100644 index 0000000000..ac52e422b2 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const toolGroupPickerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tool-group-picker', + parts: [ + { id: 'root' }, { id: 'head' }, { id: 'sections' }, { id: 'section' }, + { id: 'group' }, { id: 'groupHeader' }, { id: 'groupActions' }, + { id: 'tokenGrid' }, { id: 'token' }, { id: 'manager' }, + { id: 'managerEditor' }, { id: 'managerList' }, { id: 'managerGroup' }, + { id: 'summary' }, { id: 'summaryGroup' }, { id: 'empty' }, + ], + states: [{ id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }], +}; diff --git a/src/web-ui/src/app/scenes/assistant/appearance.ts b/src/web-ui/src/app/scenes/assistant/appearance.ts new file mode 100644 index 0000000000..74bda0bf24 --- /dev/null +++ b/src/web-ui/src/app/scenes/assistant/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const assistantAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'assistant', + parts: [{ id: 'root' }, { id: 'loading' }], +}; diff --git a/src/web-ui/src/app/scenes/browser/BrowserPanel.appearance.ts b/src/web-ui/src/app/scenes/browser/BrowserPanel.appearance.ts new file mode 100644 index 0000000000..8e9078e4aa --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/BrowserPanel.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const browserPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'browser-panel', + parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'browser-panel' }, + { id: 'toolbar', visualRole: 'toolbar', continuityGroup: 'browser-panel' }, + { id: 'address', propertyProfile: 'control', visualRole: 'control' }, + { id: 'error', visualRole: 'content' }, + { id: 'content', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'browser-panel' }, + { id: 'iframe', propertyProfile: 'layout', visualRole: 'content' }, + { id: 'webviewHost', propertyProfile: 'layout', visualRole: 'content' }, + { id: 'placeholder', visualRole: 'content' }, + ], + states: [{ id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/app/scenes/browser/appearance.ts b/src/web-ui/src/app/scenes/browser/appearance.ts new file mode 100644 index 0000000000..03d15654c5 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const browserAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'browser', + parts: [{ id: 'root' }, { id: 'toolbar' }, { id: 'error' }, { id: 'content' }], + states: [{ id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.appearance.ts b/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.appearance.ts new file mode 100644 index 0000000000..50b48ce706 --- /dev/null +++ b/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const fileViewerNavAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'file-viewer-nav', parts: [{ id: 'root' }, { id: 'header' }, { id: 'icon' }, { id: 'label' }, { id: 'actions' }] }; diff --git a/src/web-ui/src/app/scenes/file-viewer/appearance.ts b/src/web-ui/src/app/scenes/file-viewer/appearance.ts new file mode 100644 index 0000000000..f0c2435aa2 --- /dev/null +++ b/src/web-ui/src/app/scenes/file-viewer/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const fileViewerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'file-viewer', + parts: [{ id: 'root' }], +}; diff --git a/src/web-ui/src/app/scenes/git/GitNav.appearance.ts b/src/web-ui/src/app/scenes/git/GitNav.appearance.ts new file mode 100644 index 0000000000..4e421ac3bb --- /dev/null +++ b/src/web-ui/src/app/scenes/git/GitNav.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const gitNavAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'git-nav', parts: [{ id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'status' }, { id: 'actions' }, { id: 'sections' }] }; diff --git a/src/web-ui/src/app/scenes/git/appearance.ts b/src/web-ui/src/app/scenes/git/appearance.ts new file mode 100644 index 0000000000..0f3773e7f1 --- /dev/null +++ b/src/web-ui/src/app/scenes/git/appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const gitAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'git', + parts: [{ id: 'root' }, { id: 'content' }, { id: 'empty' }, { id: 'loading' }], + facets: [{ id: 'view', attribute: 'data-bf-view', values: ['hidden', 'repository', 'not-repository', 'loading'] }], +}; diff --git a/src/web-ui/src/app/scenes/git/views/BranchesView.appearance.ts b/src/web-ui/src/app/scenes/git/views/BranchesView.appearance.ts new file mode 100644 index 0000000000..c912bb6b47 --- /dev/null +++ b/src/web-ui/src/app/scenes/git/views/BranchesView.appearance.ts @@ -0,0 +1,17 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const branchesViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'branches-view', + parts: [ + { id: 'root' }, { id: 'placeholder' }, { id: 'left' }, { id: 'toolbar' }, { id: 'search' }, + { id: 'actions' }, { id: 'list' }, { id: 'empty' }, { id: 'branch' }, + { id: 'branchInfo' }, { id: 'branchActions' }, { id: 'right' }, + { id: 'historyToolbar' }, { id: 'historyTitle' }, { id: 'historyList' }, + { id: 'commit' }, { id: 'commitHeader' }, { id: 'commitInfo' }, + { id: 'commitActions' }, { id: 'commitDetails' }, + ], + states: [ + { id: 'current', selector: { kind: 'self', suffix: '[data-bf-state~="current"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/git/views/GraphView.appearance.ts b/src/web-ui/src/app/scenes/git/views/GraphView.appearance.ts new file mode 100644 index 0000000000..f322ac804f --- /dev/null +++ b/src/web-ui/src/app/scenes/git/views/GraphView.appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const gitGraphViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'git-graph-view', + parts: [{ id: 'root' }], + states: [{ id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }], +}; diff --git a/src/web-ui/src/app/scenes/git/views/WorkingCopyView.appearance.ts b/src/web-ui/src/app/scenes/git/views/WorkingCopyView.appearance.ts new file mode 100644 index 0000000000..dbe1bffbbb --- /dev/null +++ b/src/web-ui/src/app/scenes/git/views/WorkingCopyView.appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const workingCopyViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'working-copy-view', + parts: [ + { id: 'root' }, { id: 'placeholder' }, { id: 'commitBar' }, { id: 'statusRow' }, + { id: 'branch' }, { id: 'syncActions' }, { id: 'commitInput' }, { id: 'commitActions' }, + { id: 'main' }, { id: 'fileList' }, { id: 'search' }, { id: 'groupHeader' }, + { id: 'file' }, { id: 'fileCheck' }, { id: 'fileName' }, { id: 'fileStatus' }, + { id: 'resizer' }, { id: 'diffArea' }, { id: 'empty' }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/miniapps/appearance.ts b/src/web-ui/src/app/scenes/miniapps/appearance.ts new file mode 100644 index 0000000000..b22f937a30 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/appearance.ts @@ -0,0 +1,21 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const miniAppGalleryAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'miniapp-gallery', + parts: [{ id: 'root' }], +}; + +export const miniAppAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'miniapp', + parts: [ + { id: 'root' }, + { id: 'header' }, + { id: 'content' }, + { id: 'loading' }, + { id: 'error' }, + { id: 'runner' }, + { id: 'preview' }, + { id: 'notice' }, + ], + states: [{ id: 'customizing', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="customizing"]' } }], +}; diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.appearance.ts b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.appearance.ts new file mode 100644 index 0000000000..d32177b44f --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const miniAppCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'mini-app-card', parts: [{ id: 'root' }, { id: 'header' }, { id: 'iconArea' }, { id: 'icon' }, { id: 'title' }, { id: 'name' }, { id: 'version' }, { id: 'status' }, { id: 'body' }, { id: 'description' }, { id: 'tags' }, { id: 'footer' }, { id: 'actions' }] }; diff --git a/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.appearance.ts b/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.appearance.ts new file mode 100644 index 0000000000..b5c983faba --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.appearance.ts @@ -0,0 +1,20 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const miniAppCustomizePanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'miniapp-customize-panel', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'notice' }, { id: 'noticeActions' }, + { id: 'request' }, { id: 'actions' }, { id: 'error' }, { id: 'status' }, + { id: 'chat' }, { id: 'chatLoading' }, { id: 'footer' }, { id: 'busy' }, + ], + facets: [{ + id: 'stage', + attribute: 'data-bf-stage', + values: ['idle', 'notice', 'drafting', 'preview', 'permission-review', 'applying'], + }], + states: [ + { id: 'busy', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="busy"]' } }, + { id: 'error', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="error"]' } }, + { id: 'previewOpen', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="preview-open"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.appearance.ts b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.appearance.ts new file mode 100644 index 0000000000..c56e05d94c --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.appearance.ts @@ -0,0 +1,8 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const miniAppGalleryViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'miniapp-gallery-view', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'categoryFilters' }, + { id: 'categoryFilter' }, { id: 'detailTags' }, + ], +}; diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.appearance.ts b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.appearance.ts new file mode 100644 index 0000000000..b25374c976 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.appearance.ts @@ -0,0 +1,11 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const miniAppMarketViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'miniapp-market-view', + parts: [ + { id: 'root' }, + { id: 'headerActions' }, + { id: 'card' }, + { id: 'detail' }, + ], +}; diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppSubmissionsView.appearance.ts b/src/web-ui/src/app/scenes/miniapps/views/MiniAppSubmissionsView.appearance.ts new file mode 100644 index 0000000000..1a6fa2aa2f --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppSubmissionsView.appearance.ts @@ -0,0 +1,11 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const miniAppSubmissionsViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'miniapp-submissions-view', + parts: [ + { id: 'root' }, + { id: 'form' }, + { id: 'history' }, + { id: 'item' }, + ], +}; diff --git a/src/web-ui/src/app/scenes/my-agent/appearance.ts b/src/web-ui/src/app/scenes/my-agent/appearance.ts new file mode 100644 index 0000000000..783b345a98 --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const insightsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'insights', + parts: [{ id: 'root' }, { id: 'header' }, { id: 'error' }, { id: 'content' }, { id: 'loading' }, { id: 'empty' }], + facets: [{ id: 'view', attribute: 'data-bf-view', values: ['list', 'report'] }], +}; diff --git a/src/web-ui/src/app/scenes/pages/appearance.ts b/src/web-ui/src/app/scenes/pages/appearance.ts new file mode 100644 index 0000000000..4654202251 --- /dev/null +++ b/src/web-ui/src/app/scenes/pages/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const pagesAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'pages', + parts: [{ id: 'root' }, { id: 'header' }, { id: 'content' }, { id: 'loading' }, { id: 'error' }, { id: 'empty' }, { id: 'list' }], +}; diff --git a/src/web-ui/src/app/scenes/panel-view/appearance.ts b/src/web-ui/src/app/scenes/panel-view/appearance.ts new file mode 100644 index 0000000000..69e9c1df91 --- /dev/null +++ b/src/web-ui/src/app/scenes/panel-view/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const panelViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'panel-view', + parts: [{ id: 'root' }], +}; diff --git a/src/web-ui/src/app/scenes/profile/appearance.ts b/src/web-ui/src/app/scenes/profile/appearance.ts new file mode 100644 index 0000000000..2a17a4e3e4 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const profileAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'profile', + parts: [{ id: 'root' }], +}; diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantCard.appearance.ts b/src/web-ui/src/app/scenes/profile/views/AssistantCard.appearance.ts new file mode 100644 index 0000000000..fd0fd7e68c --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantCard.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const assistantCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'assistant-card', + parts: [ + { id: 'root' }, { id: 'main' }, { id: 'header' }, { id: 'avatar' }, + { id: 'headerInfo' }, { id: 'name' }, { id: 'primaryBadge' }, { id: 'badges' }, + { id: 'body' }, { id: 'vibe' }, { id: 'configure' }, { id: 'footer' }, + { id: 'newSession' }, { id: 'delete' }, { id: 'chevron' }, + ], + facets: [{ id: 'primary', attribute: 'data-bf-primary', values: ['true', 'false'] }], + states: [ + { id: 'busy', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="busy"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.appearance.ts b/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.appearance.ts new file mode 100644 index 0000000000..4cee6b92ee --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const assistantConfigPageAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'assistant-config-page', + parts: [ + { id: 'root' }, { id: 'toolbar' }, { id: 'back' }, { id: 'layout' }, + { id: 'identity' }, { id: 'sessions' }, { id: 'details' }, { id: 'personaList' }, + { id: 'persona' }, { id: 'editor' }, { id: 'editorHeader' }, { id: 'editorBody' }, + { id: 'frontmatter' }, { id: 'bodyEditor' }, { id: 'loading' }, { id: 'error' }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.appearance.ts b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.appearance.ts new file mode 100644 index 0000000000..42d6c66469 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.appearance.ts @@ -0,0 +1,18 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const assistantDefaultsPageAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'assistant-defaults-page', + parts: [ + { id: 'root' }, { id: 'toolbar' }, { id: 'content' }, { id: 'shell' }, + { id: 'main' }, { id: 'header' }, { id: 'zones' }, { id: 'split' }, + { id: 'skillList' }, { id: 'skill' }, { id: 'toolList' }, { id: 'tool' }, + { id: 'group' }, { id: 'groupHeader' }, { id: 'detail' }, + { id: 'detailHeader' }, { id: 'detailBody' }, { id: 'empty' }, { id: 'loading' }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'covered', selector: { kind: 'self', suffix: '[data-bf-state~="covered"]' } }, + { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.appearance.ts b/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.appearance.ts new file mode 100644 index 0000000000..777d552d9c --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const assistantQuickInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'assistant-quick-input', parts: [{ id: 'root' }, { id: 'box' }, { id: 'footer' }, { id: 'footerLeft' }, { id: 'hint' }] }; diff --git a/src/web-ui/src/app/scenes/profile/views/NurseryGallery.appearance.ts b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.appearance.ts new file mode 100644 index 0000000000..4d0c7c82a2 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.appearance.ts @@ -0,0 +1,8 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const nurseryGalleryAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'nursery-gallery', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'defaults' }, + { id: 'avatar' }, { id: 'defaultsContent' }, { id: 'stats' }, + ], +}; diff --git a/src/web-ui/src/app/scenes/session/AuxPane.appearance.ts b/src/web-ui/src/app/scenes/session/AuxPane.appearance.ts new file mode 100644 index 0000000000..9094c91731 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/AuxPane.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const auxPaneAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'aux-pane', parts: [{ id: 'root' }] }; diff --git a/src/web-ui/src/app/scenes/session/BottomTerminalPane.appearance.ts b/src/web-ui/src/app/scenes/session/BottomTerminalPane.appearance.ts new file mode 100644 index 0000000000..99378c1eab --- /dev/null +++ b/src/web-ui/src/app/scenes/session/BottomTerminalPane.appearance.ts @@ -0,0 +1,2 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const bottomTerminalPaneAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'bottom-terminal-pane', parts: [{ id: 'root' }] }; diff --git a/src/web-ui/src/app/scenes/session/ChatPane.appearance.ts b/src/web-ui/src/app/scenes/session/ChatPane.appearance.ts new file mode 100644 index 0000000000..c1087a1b1a --- /dev/null +++ b/src/web-ui/src/app/scenes/session/ChatPane.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const chatPaneAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'chat-pane', + parts: [ + { + id: 'root', + propertyProfile: 'layout', + visualRole: 'continuous-surface', + continuityGroup: 'session-workspace', + }, + ], +}; diff --git a/src/web-ui/src/app/scenes/session/appearance.ts b/src/web-ui/src/app/scenes/session/appearance.ts new file mode 100644 index 0000000000..7da366e1df --- /dev/null +++ b/src/web-ui/src/app/scenes/session/appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const sessionAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session', + parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'workspace', continuityGroup: 'session-workspace' }, + { id: 'main', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'session-workspace' }, + { id: 'chat', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'session-workspace' }, + { id: 'auxiliary', propertyProfile: 'layout', visualRole: 'panel', continuityGroup: 'session-workspace' }, + { id: 'terminal', propertyProfile: 'layout', visualRole: 'panel', continuityGroup: 'session-workspace' }, + ], + states: [ + { id: 'dragging', selector: { kind: 'self', suffix: '[data-bf-state~="dragging"]' } }, + { id: 'terminalBottom', selector: { kind: 'self', suffix: '[data-bf-state~="terminal-bottom"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts b/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts new file mode 100644 index 0000000000..f7a615b167 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts @@ -0,0 +1,23 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const settingsNavAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'settings-nav', + parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'panel', continuityGroup: 'settings-workspace' }, + { id: 'header', visualRole: 'toolbar', continuityGroup: 'settings-workspace' }, + { id: 'search', propertyProfile: 'control', visualRole: 'control' }, + { id: 'sections', visualRole: 'content', continuityGroup: 'settings-workspace' }, + { id: 'searchEmpty', visualRole: 'content' }, + { id: 'searchResults', visualRole: 'content' }, + { id: 'searchResult', propertyProfile: 'control', visualRole: 'control' }, + { id: 'category', visualRole: 'content' }, + { id: 'categoryHeader', visualRole: 'toolbar' }, + { id: 'items', visualRole: 'content' }, + { id: 'item', propertyProfile: 'control', visualRole: 'control' }, + { id: 'highlight', propertyProfile: 'paint', visualRole: 'decoration' }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/settings/appearance.ts b/src/web-ui/src/app/scenes/settings/appearance.ts new file mode 100644 index 0000000000..1ecbe5d72f --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/appearance.ts @@ -0,0 +1,30 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const settingsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'settings', + parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'workspace', continuityGroup: 'settings-workspace' }, + { id: 'content', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'settings-workspace' }, + { id: 'loading', propertyProfile: 'overlay', visualRole: 'content' }, + ], + facets: [{ + id: 'tab', + attribute: 'data-bf-tab', + values: [ + 'basics', + 'appearance', + 'models', + 'archived-sessions', + 'session-personalization', + 'session-permissions', + 'quick-actions', + 'review', + 'memories', + 'mcp-tools', + 'external-sources', + 'acp-agents', + 'editor', + 'keyboard', + ], + }], +}; diff --git a/src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.appearance.ts b/src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.appearance.ts new file mode 100644 index 0000000000..e664c482cd --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const archivedSessionsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'archived-sessions-config', + parts: [ + { id: 'root' }, { id: 'headerActions' }, { id: 'loading' }, { id: 'empty' }, + { id: 'group' }, { id: 'groupHeader' }, { id: 'groupList' }, { id: 'row' }, + { id: 'rowInfo' }, { id: 'rowActions' }, + ], + states: [ + { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.appearance.ts b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.appearance.ts new file mode 100644 index 0000000000..5c55e4d2cc --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.appearance.ts @@ -0,0 +1,17 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const keyboardShortcutsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'keyboard-shortcuts', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'content' }, { id: 'toolbar' }, + { id: 'search' }, { id: 'actions' }, { id: 'error' }, { id: 'list' }, + { id: 'item' }, { id: 'label' }, { id: 'key' }, { id: 'keyBadge' }, + { id: 'revert' }, { id: 'empty' }, + ], + states: [ + { id: 'recording', selector: { kind: 'self', suffix: '[data-bf-state~="recording"]' } }, + { id: 'conflict', selector: { kind: 'self', suffix: '[data-bf-state~="conflict"]' } }, + { id: 'modified', selector: { kind: 'self', suffix: '[data-bf-state~="modified"]' } }, + { id: 'readonly', selector: { kind: 'self', suffix: '[data-bf-state~="readonly"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/shell/ShellNav.appearance.ts b/src/web-ui/src/app/scenes/shell/ShellNav.appearance.ts new file mode 100644 index 0000000000..c6af8053f4 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/ShellNav.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const shellNavAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'shell-nav', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'headerActions' }, + { id: 'splitButton' }, { id: 'menu' }, { id: 'menuItem' }, + { id: 'content' }, { id: 'empty' }, { id: 'list' }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/shell/appearance.ts b/src/web-ui/src/app/scenes/shell/appearance.ts new file mode 100644 index 0000000000..69c08b172f --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/appearance.ts @@ -0,0 +1,4 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const shellAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'shell', parts: [{ id: 'root' }, { id: 'loading' }], states: [{ id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }], +}; diff --git a/src/web-ui/src/app/scenes/skills/appearance.ts b/src/web-ui/src/app/scenes/skills/appearance.ts new file mode 100644 index 0000000000..4c17c4150b --- /dev/null +++ b/src/web-ui/src/app/scenes/skills/appearance.ts @@ -0,0 +1,47 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const skillsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'skills', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'tabs' }, { id: 'tab' }, { id: 'tabDivider' }, + { id: 'content' }, { id: 'installed' }, { id: 'sidebar' }, { id: 'sidebarHeader' }, + { id: 'sidebarTitle' }, { id: 'sidebarNav' }, { id: 'sidebarItem' }, + { id: 'sidebarItemIcon' }, { id: 'sidebarItemLabel' }, { id: 'sidebarItemCount' }, + { id: 'sidebarFooter' }, { id: 'sidebarHint' }, { id: 'main' }, { id: 'toolbar' }, + { id: 'filterAction' }, { id: 'addAction' }, + { id: 'loading' }, { id: 'error' }, { id: 'empty' }, { id: 'list' }, { id: 'skeleton' }, + { id: 'installedCard' }, { id: 'installedCardTop' }, { id: 'installedCardIcon' }, + { id: 'installedCardInfo' }, { id: 'installedCardName' }, { id: 'installedCardDescription' }, + { id: 'installedCardMeta' }, { id: 'installedCardActions' }, { id: 'installedCardDelete' }, + { id: 'pagination' }, { id: 'pageButton' }, { id: 'pageInfo' }, + { id: 'discover' }, { id: 'discoverHero' }, { id: 'discoverHeroContent' }, + { id: 'discoverTitle' }, { id: 'discoverSubtitle' }, { id: 'discoverSearch' }, + { id: 'discoverContent' }, { id: 'resultsInfo' }, + { id: 'suite' }, { id: 'suiteHero' }, { id: 'suiteHeroCopy' }, { id: 'suiteHeroActions' }, + { id: 'suiteModeToolbar' }, { id: 'suiteModes' }, { id: 'suiteModeTab' }, + { id: 'suiteModeTabLabel' }, { id: 'suiteLoading' }, + { id: 'suiteSections' }, { id: 'suiteSection' }, { id: 'suiteSectionLabel' }, + { id: 'suiteGrid' }, { id: 'suiteGroupCard' }, { id: 'suiteGroupHead' }, + { id: 'suiteGroupTitleWrap' }, { id: 'suiteGroupTitleRow' }, { id: 'suiteGroupTitle' }, + { id: 'suiteGroupCount' }, { id: 'suiteGroupActions' }, { id: 'suiteSkills' }, + { id: 'suiteSkill' }, { id: 'suiteSkillName' }, { id: 'suiteSkillStatus' }, + ], + facets: [ + { id: 'tab', attribute: 'data-bf-tab', values: ['installed', 'discover'] }, + { id: 'category', attribute: 'data-bf-category', values: ['all', 'builtin', 'user', 'project', 'suite'] }, + { id: 'level', attribute: 'data-bf-level', values: ['user', 'project'] }, + { id: 'mode', attribute: 'data-bf-mode', values: ['agentic', 'Cowork', 'Claw', 'Team'] }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, + { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }, + { id: 'disabled', selector: { kind: 'self', suffix: ':disabled' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + { id: 'shadowed', selector: { kind: 'self', suffix: '[data-bf-state~="shadowed"]' } }, + { id: 'builtin', selector: { kind: 'self', suffix: '[data-bf-state~="builtin"]' } }, + { id: 'enabled', selector: { kind: 'self', suffix: '[data-bf-state~="enabled"]' } }, + { id: 'dirty', selector: { kind: 'self', suffix: '[data-bf-state~="dirty"]' } }, + { id: 'covered', selector: { kind: 'self', suffix: '[data-bf-state~="covered"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/skills/components/SkillCard.appearance.ts b/src/web-ui/src/app/scenes/skills/components/SkillCard.appearance.ts new file mode 100644 index 0000000000..55ed64fb71 --- /dev/null +++ b/src/web-ui/src/app/scenes/skills/components/SkillCard.appearance.ts @@ -0,0 +1,20 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const skillCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'skill-card', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'iconArea' }, { id: 'icon' }, + { id: 'badges' }, { id: 'body' }, { id: 'titleRow' }, { id: 'name' }, + { id: 'meta' }, { id: 'description' }, { id: 'footer' }, { id: 'actions' }, + { id: 'action' }, + ], + facets: [ + { id: 'variant', attribute: 'data-bf-variant', values: ['skill', 'market'] }, + { id: 'tone', attribute: 'data-bf-tone', values: ['primary', 'danger', 'success', 'muted'] }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, + { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/terminal/appearance.ts b/src/web-ui/src/app/scenes/terminal/appearance.ts new file mode 100644 index 0000000000..30f202bce0 --- /dev/null +++ b/src/web-ui/src/app/scenes/terminal/appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const terminalAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'terminal', + parts: [{ id: 'root' }, { id: 'empty' }], + states: [{ id: 'inactive', selector: { kind: 'self', suffix: '[data-bf-state~="inactive"]' } }], +}; diff --git a/src/web-ui/src/app/scenes/welcome/appearance.ts b/src/web-ui/src/app/scenes/welcome/appearance.ts new file mode 100644 index 0000000000..9c6bb45637 --- /dev/null +++ b/src/web-ui/src/app/scenes/welcome/appearance.ts @@ -0,0 +1,23 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const welcomeAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'welcome', parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'workspace' }, + { id: 'content', visualRole: 'card' }, + { id: 'greeting', visualRole: 'content' }, + { id: 'title', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'subtitle', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'divider', propertyProfile: 'paint', visualRole: 'divider' }, + { id: 'recentSection', visualRole: 'content' }, + { id: 'sectionHeader', visualRole: 'toolbar' }, + { id: 'sectionLabel', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'actions', visualRole: 'toolbar' }, + { id: 'action', propertyProfile: 'control', visualRole: 'control' }, + { id: 'recentList', visualRole: 'content' }, + { id: 'recentRow', visualRole: 'content' }, + { id: 'recentItem', propertyProfile: 'control', visualRole: 'control' }, + { id: 'recentName', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'recentMeta', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'empty', visualRole: 'content' }, + ], + states: [{ id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }, { id: 'disabled', selector: { kind: 'self', suffix: ':disabled' } }], +}; diff --git a/src/web-ui/src/component-library/components/Alert/appearance.ts b/src/web-ui/src/component-library/components/Alert/appearance.ts new file mode 100644 index 0000000000..8dd259f7c4 --- /dev/null +++ b/src/web-ui/src/component-library/components/Alert/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const alertAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'alert', parts: [{ id: 'root' }, { id: 'icon' }, { id: 'content' }, { id: 'title' }, { id: 'message' }, { id: 'description' }, { id: 'close' }], + facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['success', 'error', 'warning', 'info'] }], + states: [{ id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }], +}; diff --git a/src/web-ui/src/component-library/components/Avatar/appearance.ts b/src/web-ui/src/component-library/components/Avatar/appearance.ts new file mode 100644 index 0000000000..cbb9c24ca0 --- /dev/null +++ b/src/web-ui/src/component-library/components/Avatar/appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const avatarAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'avatar', + parts: [ + { id: 'root' }, { id: 'image' }, { id: 'icon' }, { id: 'text' }, + { id: 'group' }, { id: 'overflow' }, + ], + facets: [ + { id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large', 'custom'] }, + { id: 'shape', attribute: 'data-bf-shape', values: ['circle', 'square'] }, + ], +}; diff --git a/src/web-ui/src/component-library/components/Badge/appearance.ts b/src/web-ui/src/component-library/components/Badge/appearance.ts new file mode 100644 index 0000000000..2834028d82 --- /dev/null +++ b/src/web-ui/src/component-library/components/Badge/appearance.ts @@ -0,0 +1,4 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const badgeAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'badge', parts: [{ id: 'root' }], facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['neutral', 'accent', 'purple', 'success', 'warning', 'error', 'info'] }], +}; diff --git a/src/web-ui/src/component-library/components/Button/appearance.ts b/src/web-ui/src/component-library/components/Button/appearance.ts new file mode 100644 index 0000000000..7c1ff97bf4 --- /dev/null +++ b/src/web-ui/src/component-library/components/Button/appearance.ts @@ -0,0 +1,34 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const buttonAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'button', + parts: [ + { id: 'root', propertyProfile: 'control', visualRole: 'control' }, + { id: 'loadingIcon', propertyProfile: 'control', visualRole: 'decoration' }, + { id: 'loadingText', propertyProfile: 'paint', visualRole: 'content' }, + ], + facets: [ + { + id: 'variant', + attribute: 'data-bf-variant', + values: ['primary', 'secondary', 'ghost', 'dashed', 'danger', 'success', 'accent', 'ai'], + }, + { + id: 'size', + attribute: 'data-bf-size', + values: ['small', 'medium', 'large'], + }, + { + id: 'iconOnly', + attribute: 'data-bf-icon-only', + values: ['true'], + }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover:not(:disabled)' } }, + { id: 'active', selector: { kind: 'self', suffix: ':active:not(:disabled)' } }, + { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }, + { id: 'disabled', selector: { kind: 'self', suffix: ':disabled' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/Card/appearance.ts b/src/web-ui/src/component-library/components/Card/appearance.ts new file mode 100644 index 0000000000..9d60124f63 --- /dev/null +++ b/src/web-ui/src/component-library/components/Card/appearance.ts @@ -0,0 +1,43 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const cardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'card', + parts: [ + { id: 'root' }, + { id: 'header' }, + { id: 'headerContent' }, + { id: 'title' }, + { id: 'subtitle' }, + { id: 'extra' }, + { id: 'body' }, + { id: 'footer' }, + ], + facets: [ + { + id: 'variant', + attribute: 'data-bf-variant', + values: ['default', 'elevated', 'subtle', 'accent', 'purple'], + }, + { + id: 'padding', + attribute: 'data-bf-padding', + values: ['none', 'small', 'medium', 'large'], + }, + { + id: 'radius', + attribute: 'data-bf-radius', + values: ['small', 'medium', 'large'], + }, + { + id: 'align', + attribute: 'data-bf-align', + values: ['left', 'center', 'right', 'between'], + }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, + { id: 'active', selector: { kind: 'self', suffix: ':active' } }, + { id: 'interactive', selector: { kind: 'self', suffix: '[data-bf-state~="interactive"]' } }, + { id: 'fullWidth', selector: { kind: 'self', suffix: '[data-bf-state~="fullWidth"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/Checkbox/appearance.ts b/src/web-ui/src/component-library/components/Checkbox/appearance.ts new file mode 100644 index 0000000000..13cfd24275 --- /dev/null +++ b/src/web-ui/src/component-library/components/Checkbox/appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const checkboxAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'checkbox', parts: [{ id: 'root' }, { id: 'wrapper' }, { id: 'input' }, { id: 'box' }, { id: 'icon' }, { id: 'content' }, { id: 'label' }, { id: 'description' }], + facets: [{ id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }], + states: [ + { id: 'hover', selector: { kind: 'ancestorPart', part: 'root', suffix: ':hover' } }, + { id: 'focusWithin', selector: { kind: 'ancestorPart', part: 'root', suffix: ':focus-within' } }, + { id: 'checked', selector: { kind: 'ancestorPart', part: 'root', suffix: ':has(input:checked)' } }, + { id: 'indeterminate', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="indeterminate"]' } }, + { id: 'disabled', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'error', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/CodeEditor/appearance.ts b/src/web-ui/src/component-library/components/CodeEditor/appearance.ts new file mode 100644 index 0000000000..2cf9211e73 --- /dev/null +++ b/src/web-ui/src/component-library/components/CodeEditor/appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const codeEditorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'code-editor', + parts: [{ id: 'root' }, { id: 'wrapper' }, { id: 'fullscreenButton' }, { id: 'editor' }], + states: [ + { id: 'fullscreen', selector: { kind: 'self', suffix: '[data-bf-state~="fullscreen"]' } }, + { id: 'readOnly', selector: { kind: 'self', suffix: '[data-bf-state~="readOnly"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/ConfigPage/appearance.ts b/src/web-ui/src/component-library/components/ConfigPage/appearance.ts new file mode 100644 index 0000000000..6c4fe092cd --- /dev/null +++ b/src/web-ui/src/component-library/components/ConfigPage/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const configPageAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'config-page', + parts: [{ id: 'loading' }, { id: 'message' }], +}; diff --git a/src/web-ui/src/component-library/components/ConfirmDialog/appearance.ts b/src/web-ui/src/component-library/components/ConfirmDialog/appearance.ts new file mode 100644 index 0000000000..5eeda6e6f6 --- /dev/null +++ b/src/web-ui/src/component-library/components/ConfirmDialog/appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const confirmDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'confirm-dialog', + parts: [ + { id: 'root' }, { id: 'icon' }, { id: 'content' }, { id: 'title' }, + { id: 'message' }, { id: 'preview' }, { id: 'actions' }, + ], + facets: [{ id: 'type', attribute: 'data-bf-type', values: ['info', 'warning', 'error', 'success'] }], +}; diff --git a/src/web-ui/src/component-library/components/CubeLoading/appearance.ts b/src/web-ui/src/component-library/components/CubeLoading/appearance.ts new file mode 100644 index 0000000000..e0c2d204f0 --- /dev/null +++ b/src/web-ui/src/component-library/components/CubeLoading/appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const cubeLoadingAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'cube-loading', + parts: [{ id: 'root' }, { id: 'text' }], + facets: [{ id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }], +}; diff --git a/src/web-ui/src/component-library/components/CubeLogo/appearance.ts b/src/web-ui/src/component-library/components/CubeLogo/appearance.ts new file mode 100644 index 0000000000..e3aeb33031 --- /dev/null +++ b/src/web-ui/src/component-library/components/CubeLogo/appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const cubeLogoAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'cube-logo', + parts: [ + { id: 'root' }, { id: 'particles' }, { id: 'particle' }, { id: 'scene' }, + { id: 'cube' }, { id: 'block' }, { id: 'face' }, + ], + facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['default', 'compact'] }], +}; diff --git a/src/web-ui/src/component-library/components/DotMatrixLoader/appearance.ts b/src/web-ui/src/component-library/components/DotMatrixLoader/appearance.ts new file mode 100644 index 0000000000..fcd058afcf --- /dev/null +++ b/src/web-ui/src/component-library/components/DotMatrixLoader/appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const dotMatrixLoaderAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'dot-matrix-loader', + parts: [{ id: 'root' }, { id: 'dot' }], + facets: [{ id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }], +}; diff --git a/src/web-ui/src/component-library/components/Empty/appearance.ts b/src/web-ui/src/component-library/components/Empty/appearance.ts new file mode 100644 index 0000000000..3eabcf4f44 --- /dev/null +++ b/src/web-ui/src/component-library/components/Empty/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const emptyAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'empty', + parts: [{ id: 'root' }, { id: 'image' }, { id: 'description' }, { id: 'footer' }], +}; diff --git a/src/web-ui/src/component-library/components/FilterPill/appearance.ts b/src/web-ui/src/component-library/components/FilterPill/appearance.ts new file mode 100644 index 0000000000..ceb0c251b3 --- /dev/null +++ b/src/web-ui/src/component-library/components/FilterPill/appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const filterPillAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'filter-pill', + parts: [{ id: 'root' }, { id: 'label' }, { id: 'count' }, { id: 'group' }], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/appearance.ts b/src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/appearance.ts new file mode 100644 index 0000000000..85c0407085 --- /dev/null +++ b/src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const contextCompressionCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'context-compression-card', + parts: [ + { id: 'compact' }, { id: 'status' }, { id: 'action' }, { id: 'tokens' }, + { id: 'result' }, { id: 'processing' }, { id: 'summaryRow' }, { id: 'statsRow' }, + ], + facets: [ + { id: 'display', attribute: 'data-bf-display', values: ['compact'] }, + { id: 'status', attribute: 'data-bf-status', values: ['pending', 'preparing', 'running', 'streaming', 'completed', 'cancelled', 'error'] }, + ], +}; diff --git a/src/web-ui/src/component-library/components/FlowChatCards/appearance.ts b/src/web-ui/src/component-library/components/FlowChatCards/appearance.ts new file mode 100644 index 0000000000..86d5bbaea5 --- /dev/null +++ b/src/web-ui/src/component-library/components/FlowChatCards/appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const flowChatCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'flow-chat-card', + parts: [ + { id: 'root' }, { id: 'compact' }, { id: 'header' }, { id: 'content' }, + { id: 'status' }, { id: 'actions' }, { id: 'result' }, { id: 'error' }, + { id: 'details' }, { id: 'list' }, { id: 'item' }, { id: 'empty' }, + { id: 'processing' }, { id: 'processingDot' }, + ], + facets: [ + { id: 'display', attribute: 'data-bf-display', values: ['compact', 'normal', 'detailed'] }, + { id: 'status', attribute: 'data-bf-status', values: ['pending', 'preparing', 'running', 'streaming', 'completed', 'cancelled', 'error'] }, + ], +}; diff --git a/src/web-ui/src/component-library/components/IconButton/appearance.ts b/src/web-ui/src/component-library/components/IconButton/appearance.ts new file mode 100644 index 0000000000..feb386edff --- /dev/null +++ b/src/web-ui/src/component-library/components/IconButton/appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const iconButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'icon-button', parts: [{ id: 'root' }], + facets: [ + { id: 'variant', attribute: 'data-bf-variant', values: ['default', 'primary', 'ghost', 'danger', 'success', 'warning', 'ai'] }, + { id: 'size', attribute: 'data-bf-size', values: ['xs', 'small', 'medium', 'large'] }, + { id: 'shape', attribute: 'data-bf-shape', values: ['square', 'circle'] }, + ], + states: [{ id: 'hover', selector: { kind: 'self', suffix: ':hover:not(:disabled)' } }, { id: 'active', selector: { kind: 'self', suffix: ':active:not(:disabled)' } }, { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }, { id: 'disabled', selector: { kind: 'self', suffix: ':disabled' } }, { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/component-library/components/Input/appearance.ts b/src/web-ui/src/component-library/components/Input/appearance.ts new file mode 100644 index 0000000000..1797eecd40 --- /dev/null +++ b/src/web-ui/src/component-library/components/Input/appearance.ts @@ -0,0 +1,33 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const inputAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'input', + parts: [ + { id: 'root', propertyProfile: 'control', visualRole: 'control' }, + { id: 'label', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'container', propertyProfile: 'control', visualRole: 'control' }, + { id: 'control', propertyProfile: 'control', visualRole: 'control' }, + { id: 'prefix', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'suffix', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'message', propertyProfile: 'paint', visualRole: 'content' }, + ], + facets: [ + { + id: 'variant', + attribute: 'data-bf-variant', + values: ['default', 'filled', 'outlined'], + }, + { + id: 'size', + attribute: 'data-bf-size', + values: ['small', 'medium', 'large'], + }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, + { id: 'focusWithin', selector: { kind: 'self', suffix: ':focus-within' } }, + { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/InputDialog/appearance.ts b/src/web-ui/src/component-library/components/InputDialog/appearance.ts new file mode 100644 index 0000000000..ebca55203e --- /dev/null +++ b/src/web-ui/src/component-library/components/InputDialog/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const inputDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'input-dialog', + parts: [{ id: 'root' }, { id: 'body' }, { id: 'description' }, { id: 'actions' }], +}; diff --git a/src/web-ui/src/component-library/components/Markdown/MermaidBlock.appearance.ts b/src/web-ui/src/component-library/components/Markdown/MermaidBlock.appearance.ts new file mode 100644 index 0000000000..2d5b4bd6f3 --- /dev/null +++ b/src/web-ui/src/component-library/components/Markdown/MermaidBlock.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const mermaidBlockAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'mermaid-block', + parts: [ + { id: 'root' }, { id: 'streaming' }, { id: 'codePreview' }, { id: 'code' }, + { id: 'hint' }, { id: 'loading' }, { id: 'rendered' }, { id: 'diagram' }, + { id: 'actions' }, { id: 'action' }, { id: 'source' }, { id: 'error' }, + ], + states: [ + { id: 'streaming', selector: { kind: 'self', suffix: '[data-bf-state~="streaming"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'copied', selector: { kind: 'self', suffix: '[data-bf-state~="copied"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/Markdown/appearance.ts b/src/web-ui/src/component-library/components/Markdown/appearance.ts new file mode 100644 index 0000000000..e89da074be --- /dev/null +++ b/src/web-ui/src/component-library/components/Markdown/appearance.ts @@ -0,0 +1,23 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const markdownAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'markdown', + parts: [ + { id: 'root', visualRole: 'content' }, { id: 'fallback', visualRole: 'content' }, + { id: 'codeBlock', visualRole: 'card' }, + { id: 'codeToolbar', propertyProfile: 'control', visualRole: 'toolbar' }, + { id: 'codeBody', visualRole: 'content' }, + { id: 'codePre', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'codeContent', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'fileLink' }, { id: 'visualizationLink' }, { id: 'tabLink' }, + { id: 'table' }, { id: 'blockquote' }, { id: 'reproduction' }, + { id: 'reproductionHeader' }, { id: 'reproductionContent' }, { id: 'reproductionActions' }, + { id: 'math' }, + ], + states: [ + { id: 'fallback', selector: { kind: 'self', suffix: '[data-bf-state~="fallback"]' } }, + { id: 'streaming', selector: { kind: 'self', suffix: '[data-bf-state~="streaming"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/Modal/appearance.ts b/src/web-ui/src/component-library/components/Modal/appearance.ts new file mode 100644 index 0000000000..85c6516856 --- /dev/null +++ b/src/web-ui/src/component-library/components/Modal/appearance.ts @@ -0,0 +1,43 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const modalAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'modal', + parts: [ + { id: 'overlay', propertyProfile: 'overlay', visualRole: 'popup' }, + { id: 'dialog', propertyProfile: 'overlay', visualRole: 'dialog' }, + { id: 'headerShell', visualRole: 'toolbar', continuityGroup: 'modal-dialog' }, + { id: 'header', visualRole: 'toolbar', continuityGroup: 'modal-dialog' }, + { id: 'titleGroup', visualRole: 'content' }, + { id: 'title', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'titleExtra', visualRole: 'content' }, + { id: 'close', propertyProfile: 'control', visualRole: 'control' }, + { id: 'content', visualRole: 'content', continuityGroup: 'modal-dialog' }, + { id: 'resizeHandle', propertyProfile: 'overlay', visualRole: 'control' }, + ], + facets: [ + { + id: 'size', + attribute: 'data-bf-size', + values: ['small', 'medium', 'large', 'xlarge'], + }, + { + id: 'placement', + attribute: 'data-bf-placement', + values: ['center', 'bottom-left', 'bottom-right'], + }, + { + id: 'resizeDirection', + attribute: 'data-bf-resize-direction', + values: ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'], + }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, + { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }, + { id: 'draggable', selector: { kind: 'self', suffix: '[data-bf-state~="draggable"]' } }, + { id: 'dragging', selector: { kind: 'self', suffix: '[data-bf-state~="dragging"]' } }, + { id: 'resizable', selector: { kind: 'self', suffix: '[data-bf-state~="resizable"]' } }, + { id: 'resizing', selector: { kind: 'self', suffix: '[data-bf-state~="resizing"]' } }, + { id: 'contentInset', selector: { kind: 'self', suffix: '[data-bf-state~="contentInset"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/NumberInput/appearance.ts b/src/web-ui/src/component-library/components/NumberInput/appearance.ts new file mode 100644 index 0000000000..06b9b2e678 --- /dev/null +++ b/src/web-ui/src/component-library/components/NumberInput/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const numberInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'number-input', parts: [{ id: 'root' }, { id: 'label' }, { id: 'container' }, { id: 'glow' }, { id: 'valueArea' }, { id: 'input' }, { id: 'unit' }, { id: 'buttons' }, { id: 'decrement' }, { id: 'increment' }, { id: 'progress' }, { id: 'progressBar' }], + facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['default', 'compact', 'stepper'] }, { id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }], + states: [{ id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'focusWithin', selector: { kind: 'self', suffix: ':focus-within' } }, { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, { id: 'editing', selector: { kind: 'self', suffix: '[data-bf-state~="editing"]' } }, { id: 'dragging', selector: { kind: 'self', suffix: '[data-bf-state~="dragging"]' } }], +}; diff --git a/src/web-ui/src/component-library/components/Search/appearance.ts b/src/web-ui/src/component-library/components/Search/appearance.ts new file mode 100644 index 0000000000..3e04a32226 --- /dev/null +++ b/src/web-ui/src/component-library/components/Search/appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const searchAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'search', parts: [ + { id: 'root', propertyProfile: 'control', visualRole: 'control' }, + { id: 'wrapper', propertyProfile: 'control', visualRole: 'control' }, + { id: 'prefix', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'input', propertyProfile: 'control', visualRole: 'control' }, + { id: 'clear', propertyProfile: 'control', visualRole: 'control' }, + { id: 'suffix', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'button', propertyProfile: 'control', visualRole: 'control' }, + { id: 'message', propertyProfile: 'paint', visualRole: 'content' }, + ], + facets: [{ id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }], + states: [{ id: 'hover', selector: { kind: 'self', suffix: '[data-bf-state~="hover"]' } }, { id: 'focusWithin', selector: { kind: 'self', suffix: '[data-bf-state~="focused"]' } }, { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, { id: 'expandable', selector: { kind: 'self', suffix: '[data-bf-state~="expandable"]' } }], +}; diff --git a/src/web-ui/src/component-library/components/Select/appearance.ts b/src/web-ui/src/component-library/components/Select/appearance.ts new file mode 100644 index 0000000000..247aca0b74 --- /dev/null +++ b/src/web-ui/src/component-library/components/Select/appearance.ts @@ -0,0 +1,27 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const selectAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'select', parts: [ + { id: 'root', propertyProfile: 'control', visualRole: 'control' }, + { id: 'label', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'trigger', propertyProfile: 'control', visualRole: 'control' }, + { id: 'value', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'suffix', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'loading', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'clear', propertyProfile: 'control', visualRole: 'control' }, + { id: 'arrow', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'dropdown', propertyProfile: 'overlay', visualRole: 'popup' }, + { id: 'search', propertyProfile: 'control', visualRole: 'control' }, + { id: 'searchInput', propertyProfile: 'control', visualRole: 'control' }, + { id: 'options', visualRole: 'content' }, + { id: 'option', propertyProfile: 'control', visualRole: 'control' }, + { id: 'optionIcon', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'optionLabel', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'optionDescription', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'group', visualRole: 'content' }, + { id: 'groupLabel', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'empty', visualRole: 'content' }, + { id: 'message', propertyProfile: 'paint', visualRole: 'content' }, + ], + facets: [{ id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }, { id: 'placement', attribute: 'data-bf-placement', values: ['top', 'bottom'] }, { id: 'multiple', attribute: 'data-bf-multiple', values: ['true', 'false'] }], + states: [{ id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'focusWithin', selector: { kind: 'self', suffix: ':focus-within' } }, { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, { id: 'highlighted', selector: { kind: 'self', suffix: '[data-bf-state~="highlighted"]' } }, { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/component-library/components/StreamText/appearance.ts b/src/web-ui/src/component-library/components/StreamText/appearance.ts new file mode 100644 index 0000000000..149a554c1a --- /dev/null +++ b/src/web-ui/src/component-library/components/StreamText/appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const streamTextAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'stream-text', + parts: [{ id: 'root' }, { id: 'content' }, { id: 'character' }, { id: 'cursor' }], + facets: [ + { id: 'effect', attribute: 'data-bf-effect', values: ['smooth', 'typewriter', 'wave', 'fade', 'glitch', 'neon', 'matrix', 'gradient', 'pulse', 'blur', 'bounce', 'shimmer'] }, + { id: 'color', attribute: 'data-bf-color', values: ['blue', 'purple', 'green', 'rainbow', 'fire', 'ocean', 'sunset'] }, + ], + states: [ + { id: 'streaming', selector: { kind: 'self', suffix: '[data-bf-state~="streaming"]' } }, + { id: 'complete', selector: { kind: 'self', suffix: '[data-bf-state~="complete"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/components/Switch/appearance.ts b/src/web-ui/src/component-library/components/Switch/appearance.ts new file mode 100644 index 0000000000..1caa7eb9b2 --- /dev/null +++ b/src/web-ui/src/component-library/components/Switch/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const switchAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'switch', parts: [{ id: 'root' }, { id: 'wrapper' }, { id: 'input' }, { id: 'track' }, { id: 'text' }, { id: 'thumb' }, { id: 'loading' }, { id: 'content' }, { id: 'label' }, { id: 'description' }], + facets: [{ id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }], + states: [{ id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'focusWithin', selector: { kind: 'self', suffix: ':focus-within' } }, { id: 'checked', selector: { kind: 'self', suffix: '[data-bf-state~="checked"]' } }, { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/component-library/components/Tabs/appearance.ts b/src/web-ui/src/component-library/components/Tabs/appearance.ts new file mode 100644 index 0000000000..57fcd47728 --- /dev/null +++ b/src/web-ui/src/component-library/components/Tabs/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const tabsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tabs', parts: [{ id: 'root' }, { id: 'nav' }, { id: 'navList' }, { id: 'tab' }, { id: 'icon' }, { id: 'label' }, { id: 'close' }, { id: 'inkBar' }, { id: 'content' }, { id: 'pane' }], + facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['line', 'card', 'pill'] }, { id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }], + states: [{ id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, { id: 'stretch', selector: { kind: 'self', suffix: '[data-bf-state~="stretch"]' } }], +}; diff --git a/src/web-ui/src/component-library/components/Tag/appearance.ts b/src/web-ui/src/component-library/components/Tag/appearance.ts new file mode 100644 index 0000000000..7f221fc5f7 --- /dev/null +++ b/src/web-ui/src/component-library/components/Tag/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const tagAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tag', parts: [{ id: 'root' }, { id: 'content' }, { id: 'close' }], + facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['blue', 'green', 'red', 'yellow', 'purple', 'gray'] }, { id: 'size', attribute: 'data-bf-size', values: ['small', 'medium', 'large'] }], + states: [{ id: 'rounded', selector: { kind: 'self', suffix: '[data-bf-state~="rounded"]' } }, { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }], +}; diff --git a/src/web-ui/src/component-library/components/TaskRunningIndicator/appearance.ts b/src/web-ui/src/component-library/components/TaskRunningIndicator/appearance.ts new file mode 100644 index 0000000000..bae35e5be9 --- /dev/null +++ b/src/web-ui/src/component-library/components/TaskRunningIndicator/appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const taskRunningIndicatorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'task-running-indicator', + parts: [{ id: 'root' }, { id: 'bar' }], + facets: [{ id: 'size', attribute: 'data-bf-size', values: ['xs', 'sm', 'md', 'lg'] }], +}; diff --git a/src/web-ui/src/component-library/components/TextStrokeEffect/appearance.ts b/src/web-ui/src/component-library/components/TextStrokeEffect/appearance.ts new file mode 100644 index 0000000000..af35822285 --- /dev/null +++ b/src/web-ui/src/component-library/components/TextStrokeEffect/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const textStrokeEffectAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'text-stroke-effect', + parts: [{ id: 'root' }, { id: 'outline' }, { id: 'animated' }, { id: 'gradient' }], +}; diff --git a/src/web-ui/src/component-library/components/Textarea/appearance.ts b/src/web-ui/src/component-library/components/Textarea/appearance.ts new file mode 100644 index 0000000000..e1be5cc3c9 --- /dev/null +++ b/src/web-ui/src/component-library/components/Textarea/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const textareaAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'textarea', parts: [{ id: 'root' }, { id: 'label' }, { id: 'required' }, { id: 'wrapper' }, { id: 'field' }, { id: 'footer' }, { id: 'message' }, { id: 'count' }], + facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['default', 'filled', 'outlined'] }], + states: [{ id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'focusWithin', selector: { kind: 'self', suffix: ':focus-within' } }, { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, { id: 'autoResize', selector: { kind: 'self', suffix: '[data-bf-state~="autoResize"]' } }], +}; diff --git a/src/web-ui/src/component-library/components/Tooltip/appearance.ts b/src/web-ui/src/component-library/components/Tooltip/appearance.ts new file mode 100644 index 0000000000..63a3e5cf42 --- /dev/null +++ b/src/web-ui/src/component-library/components/Tooltip/appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; +export const tooltipAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tooltip', parts: [{ id: 'root' }, { id: 'arrow' }, { id: 'content' }, { id: 'body' }], + facets: [{ id: 'placement', attribute: 'data-bf-placement', values: ['top', 'bottom', 'left', 'right'] }, { id: 'interactive', attribute: 'data-bf-interactive', values: ['true', 'false'] }], + states: [{ id: 'visible', selector: { kind: 'self', suffix: '[data-bf-state~="visible"]' } }], +}; diff --git a/src/web-ui/src/component-library/components/WindowControls/appearance.ts b/src/web-ui/src/component-library/components/WindowControls/appearance.ts new file mode 100644 index 0000000000..2c66bd3f20 --- /dev/null +++ b/src/web-ui/src/component-library/components/WindowControls/appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const windowControlsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'window-controls', + parts: [{ id: 'root' }, { id: 'minimize' }, { id: 'maximize' }, { id: 'close' }], + states: [ + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'maximized', selector: { kind: 'self', suffix: '[data-bf-state~="maximized"]' } }, + ], +}; diff --git a/src/web-ui/src/component-library/preview/appearance.ts b/src/web-ui/src/component-library/preview/appearance.ts new file mode 100644 index 0000000000..d39fd6dea1 --- /dev/null +++ b/src/web-ui/src/component-library/preview/appearance.ts @@ -0,0 +1,58 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const componentPreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'component-preview', + parts: [ + { id: 'root' }, + { id: 'header' }, + { id: 'logo' }, + { id: 'headerActions' }, + { id: 'appearanceSelector' }, + { id: 'container' }, + { id: 'sidebar' }, + { id: 'sidebarHeader' }, + { id: 'sidebarToggle' }, + { id: 'navigation' }, + { id: 'category' }, + { id: 'main' }, + { id: 'componentHeader' }, + { id: 'emptyState' }, + { id: 'markdownRoot' }, + { id: 'markdownHeader' }, + { id: 'markdownControls' }, + { id: 'markdownStage' }, + { id: 'markdownEditor' }, + { id: 'columnRoot' }, + { id: 'columnNavigation' }, + { id: 'columnItem' }, + { id: 'columnPreview' }, + { id: 'demoRoot' }, + { id: 'demoCard' }, + { id: 'demoHeader' }, + { id: 'demoStage' }, + { id: 'demoFooter' }, + { id: 'fullPageRoot' }, + { id: 'fullPageTabs' }, + { id: 'fullPageTab' }, + { id: 'fullPageContent' }, + { id: 'gridRoot' }, + { id: 'gridCard' }, + { id: 'gridHeader' }, + { id: 'gridPreview' }, + { id: 'gridCanvas' }, + { id: 'gridInfo' }, + { id: 'largeCardRoot' }, + { id: 'largeCard' }, + { id: 'largeCardHeader' }, + { id: 'largeCardPreview' }, + { id: 'expandButton' }, + ], + facets: [ + { id: 'columns', attribute: 'data-bf-columns', values: ['2', '3', '4'] }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/features/dispatch/appearance.ts b/src/web-ui/src/features/dispatch/appearance.ts new file mode 100644 index 0000000000..76ee074f6c --- /dev/null +++ b/src/web-ui/src/features/dispatch/appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const dispatchInstallDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'dispatch-install-dialog', + parts: [{ id: 'root' }, { id: 'header' }, { id: 'body' }, { id: 'actions' }], +}; + +export const dispatchResultDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'dispatch-result-dialog', + parts: [{ id: 'root' }, { id: 'header' }, { id: 'body' }, { id: 'actions' }], +}; + +export const dispatchTargetPickerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'dispatch-target-picker', + parts: [{ id: 'root' }, { id: 'trigger' }, { id: 'menu' }, { id: 'option' }], +}; diff --git a/src/web-ui/src/features/relay-deploy/appearance.ts b/src/web-ui/src/features/relay-deploy/appearance.ts new file mode 100644 index 0000000000..bbef739be3 --- /dev/null +++ b/src/web-ui/src/features/relay-deploy/appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const relayDeployAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'relay-deploy', + parts: [ + { id: 'root' }, + { id: 'steps' }, + { id: 'step' }, + { id: 'error' }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'completed', selector: { kind: 'self', suffix: '[data-bf-state~="completed"]' } }, + ], +}; diff --git a/src/web-ui/src/features/ssh-remote/appearance.ts b/src/web-ui/src/features/ssh-remote/appearance.ts new file mode 100644 index 0000000000..4f5750a6a8 --- /dev/null +++ b/src/web-ui/src/features/ssh-remote/appearance.ts @@ -0,0 +1,30 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sshRemoteAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'ssh-remote', + parts: [ + { id: 'connection' }, + { id: 'connectionError' }, + { id: 'connectionContent' }, + { id: 'connectionSection' }, + { id: 'connectionList' }, + { id: 'connectionItem' }, + { id: 'connectionForm' }, + { id: 'connectionActions' }, + { id: 'browserOverlay' }, + { id: 'browser' }, + { id: 'browserHeader' }, + { id: 'breadcrumb' }, + { id: 'toolbar' }, + { id: 'content' }, + { id: 'footer' }, + { id: 'authDialog' }, + { id: 'authDescription' }, + { id: 'authField' }, + { id: 'authActions' }, + { id: 'confirmDialog' }, + { id: 'confirmWarning' }, + { id: 'confirmMessage' }, + { id: 'confirmActions' }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/AcpPlanPanel.appearance.ts b/src/web-ui/src/flow_chat/components/AcpPlanPanel.appearance.ts new file mode 100644 index 0000000000..47db78c0b4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/AcpPlanPanel.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const acpPlanPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'acp-plan-panel', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'progress' }, + { id: 'list' }, { id: 'item' }, { id: 'content' }, + ], + facets: [{ id: 'status', attribute: 'data-bf-status', values: ['completed', 'in_progress', 'pending'] }], +}; diff --git a/src/web-ui/src/flow_chat/components/ChatEmptyState.appearance.ts b/src/web-ui/src/flow_chat/components/ChatEmptyState.appearance.ts new file mode 100644 index 0000000000..713ecb51ed --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatEmptyState.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const chatEmptyStateAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'chat-empty-state', + parts: [ + { id: 'root' }, { id: 'container' }, { id: 'greeting' }, + { id: 'divider' }, { id: 'prompt' }, { id: 'noWorkspace' }, + ], + states: [{ id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts new file mode 100644 index 0000000000..271ed97270 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts @@ -0,0 +1,71 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const chatInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'chat-input', + parts: [ + { id: 'root' }, + { id: 'container' }, + { id: 'box' }, + { id: 'targetSwitcher' }, + { id: 'targetLabel' }, + { id: 'target' }, + { id: 'targetName' }, + { id: 'area' }, + { id: 'imageStrip' }, + { id: 'image' }, + { id: 'imagePreview' }, + { id: 'imageRemove' }, + { id: 'placeholder' }, + { id: 'commandPicker' }, + { id: 'commandHeader' }, + { id: 'commandList' }, + { id: 'commandItem' }, + { id: 'commandName' }, + { id: 'commandLabel' }, + { id: 'commandCurrent' }, + { id: 'commandSection' }, + { id: 'commandEmpty' }, + { id: 'actions' }, + { id: 'actionsLeft' }, + { id: 'actionsRight' }, + { id: 'boost' }, + { id: 'boostTrigger' }, + { id: 'modeChip' }, + { id: 'modeChipLabel' }, + { id: 'modeChipRemove' }, + { id: 'boostMenu' }, + { id: 'boostSection' }, + { id: 'boostItem' }, + { id: 'boostItemLabel' }, + { id: 'boostItemMeta' }, + { id: 'boostDefaultAction' }, + { id: 'boostDivider' }, + { id: 'boostSubmenuTrigger' }, + { id: 'boostSubmenuPanel' }, + { id: 'boostSubmenuItem' }, + { id: 'boostSubmenuState' }, + { id: 'boostSubmenuManage' }, + { id: 'model' }, + { id: 'sendActions' }, + { id: 'sendButton' }, + { id: 'queuedBadge' }, + ], + facets: [ + { id: 'target', attribute: 'data-bf-target', values: ['main', 'btw'] }, + { id: 'command', attribute: 'data-bf-command', values: ['actions', 'all', 'skills', 'modes'] }, + { id: 'commandItemKind', attribute: 'data-bf-command-item-kind', values: ['action', 'mode', 'skill', 'mcp'] }, + { id: 'action', attribute: 'data-bf-action', values: ['cancel', 'retry', 'send', 'split'] }, + { id: 'boostItemKind', attribute: 'data-bf-boost-item-kind', values: ['mode', 'context', 'skill', 'manage'] }, + ], + states: [ + { id: 'processing', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="processing"]' } }, + { id: 'multiline', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="multiline"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'current', selector: { kind: 'self', suffix: '[data-bf-state~="current"]' } }, + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'pending', selector: { kind: 'self', suffix: '[data-bf-state~="pending"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/ChatInputPixelPet.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInputPixelPet.appearance.ts new file mode 100644 index 0000000000..24521bf6f0 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputPixelPet.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const chatInputPixelPetAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'chat-input-pixel-pet', + parts: [ + { id: 'root' }, { id: 'stage' }, { id: 'svg' }, { id: 'silhouette' }, + { id: 'face' }, { id: 'rest' }, { id: 'analyze' }, { id: 'wait' }, + { id: 'work' }, { id: 'hover' }, { id: 'drag' }, { id: 'petdex' }, + ], + facets: [ + { id: 'mood', attribute: 'data-bf-mood', values: ['rest', 'analyzing', 'waiting', 'working', 'hover', 'dragging'] }, + { id: 'layout', attribute: 'data-bf-layout', values: ['default', 'center', 'stopRight', 'petdex'] }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts new file mode 100644 index 0000000000..29e537134e --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const chatInputWorkspaceStripAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'chat-input-workspace-strip', + parts: [ + { id: 'root' }, { id: 'main' }, { id: 'workspace' }, { id: 'branch' }, + { id: 'actions' }, { id: 'permission' }, { id: 'permissionMenu' }, + { id: 'permissionOptions' }, { id: 'permissionOption' }, { id: 'usageAction' }, + ], + states: [ + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/CodePreview.appearance.ts b/src/web-ui/src/flow_chat/components/CodePreview.appearance.ts new file mode 100644 index 0000000000..e58d6110a7 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/CodePreview.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const codePreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'code-preview', + parts: [ + { id: 'root' }, { id: 'placeholder' }, { id: 'content' }, { id: 'plain' }, + { id: 'line' }, { id: 'lineNumber' }, { id: 'lineContent' }, { id: 'cursor' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + { id: 'streaming', selector: { kind: 'self', suffix: '[data-bf-state~="streaming"]' } }, + { id: 'highlighted', selector: { kind: 'self', suffix: '[data-bf-state~="highlighted"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/CopyOutputButton.appearance.ts b/src/web-ui/src/flow_chat/components/CopyOutputButton.appearance.ts new file mode 100644 index 0000000000..432805183e --- /dev/null +++ b/src/web-ui/src/flow_chat/components/CopyOutputButton.appearance.ts @@ -0,0 +1,8 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const copyOutputButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'copy-output-button', + parts: [{ id: 'root' }, { id: 'action' }, { id: 'icon' }, { id: 'text' }], + facets: [{ id: 'action', attribute: 'data-bf-action', values: ['copy', 'edit'] }], + states: [{ id: 'copied', selector: { kind: 'self', suffix: '[data-bf-state~="copied"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/CopyableTextPreview.appearance.ts b/src/web-ui/src/flow_chat/components/CopyableTextPreview.appearance.ts new file mode 100644 index 0000000000..ec710f35cf --- /dev/null +++ b/src/web-ui/src/flow_chat/components/CopyableTextPreview.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const copyableTextPreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'copyable-text-preview', + parts: [ + { id: 'root' }, + { id: 'empty' }, + { id: 'tooltipContent' }, + { id: 'tooltipText' }, + { id: 'copyAction' }, + ], + states: [ + { id: 'copied', selector: { kind: 'self', suffix: '[data-bf-state~="copied"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/CoworkExampleCards.appearance.ts b/src/web-ui/src/flow_chat/components/CoworkExampleCards.appearance.ts new file mode 100644 index 0000000000..818b02b84f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/CoworkExampleCards.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const coworkExampleCardsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'cowork-example-cards', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'actions' }, + { id: 'grid' }, { id: 'card' }, { id: 'cardHeader' }, { id: 'cardIcon' }, + { id: 'cardTitle' }, { id: 'cardDescription' }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/CurrentSessionTitle.appearance.ts b/src/web-ui/src/flow_chat/components/CurrentSessionTitle.appearance.ts new file mode 100644 index 0000000000..5ec3beda4e --- /dev/null +++ b/src/web-ui/src/flow_chat/components/CurrentSessionTitle.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const currentSessionTitleAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'current-session-title', + parts: [ + { id: 'root', visualRole: 'toolbar', continuityGroup: 'session-header' }, + { id: 'title', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'create', propertyProfile: 'control', visualRole: 'control' }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.appearance.ts b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.appearance.ts new file mode 100644 index 0000000000..ccbc8a48a2 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.appearance.ts @@ -0,0 +1,11 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const deepReviewConsentDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'deep-review-consent-dialog', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'heading' }, { id: 'close' }, + { id: 'capacityNote' }, { id: 'summary' }, { id: 'summaryHeader' }, + { id: 'summaryStats' }, { id: 'impactGrid' }, + { id: 'strategy' }, { id: 'reviewerGroup' }, { id: 'footer' }, { id: 'actions' }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.appearance.ts b/src/web-ui/src/flow_chat/components/FileMentionPicker.appearance.ts new file mode 100644 index 0000000000..9690ed4ec5 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const fileMentionPickerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'file-mention-picker', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'back' }, { id: 'content' }, + { id: 'loading' }, { id: 'empty' }, { id: 'list' }, { id: 'item' }, + { id: 'itemName' }, { id: 'itemDetail' }, { id: 'footer' }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.appearance.ts b/src/web-ui/src/flow_chat/components/FlowTextBlock.appearance.ts new file mode 100644 index 0000000000..aaac01e577 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const flowTextBlockAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'flow-text-block', + parts: [ + { id: 'root' }, { id: 'textContent' }, + ], + facets: [{ id: 'mode', attribute: 'data-bf-mode', values: ['markdown', 'text'] }], + states: [{ id: 'streaming', selector: { kind: 'self', suffix: '[data-bf-state~="streaming"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/ImageAnalysisCard.appearance.ts b/src/web-ui/src/flow_chat/components/ImageAnalysisCard.appearance.ts new file mode 100644 index 0000000000..3e280a8ecd --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ImageAnalysisCard.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const imageAnalysisCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'image-analysis-card', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'thumbnail' }, { id: 'placeholder' }, + { id: 'info' }, { id: 'filename' }, { id: 'status' }, { id: 'content' }, + { id: 'summary' }, { id: 'expand' }, { id: 'details' }, { id: 'section' }, + { id: 'tags' }, { id: 'metadata' }, { id: 'error' }, + ], + facets: [{ id: 'status', attribute: 'data-bf-status', values: ['analyzing', 'completed', 'error'] }], + states: [{ id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/InlineDiffPreview.appearance.ts b/src/web-ui/src/flow_chat/components/InlineDiffPreview.appearance.ts new file mode 100644 index 0000000000..c536122c28 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/InlineDiffPreview.appearance.ts @@ -0,0 +1,12 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const inlineDiffPreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'inline-diff-preview', + parts: [ + { id: 'root' }, { id: 'placeholder' }, { id: 'notice' }, { id: 'content' }, + { id: 'line' }, { id: 'gutter' }, { id: 'prefix' }, { id: 'lineContent' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts b/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts new file mode 100644 index 0000000000..36ae50a3f7 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts @@ -0,0 +1,21 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const modelSelectorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'model-selector', + parts: [ + { id: 'root' }, + { id: 'trigger' }, + { id: 'name' }, + { id: 'contextUsage' }, + { id: 'dropdown' }, + { id: 'dropdownHeader' }, + { id: 'list' }, + { id: 'option' }, + { id: 'optionMain' }, + { id: 'configRow' }, + ], + states: [ + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.appearance.ts b/src/web-ui/src/flow_chat/components/PendingQueuePanel.appearance.ts new file mode 100644 index 0000000000..51f9f2877b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const pendingQueuePanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'pending-queue-panel', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'list' }, + { id: 'item' }, { id: 'content' }, { id: 'editor' }, { id: 'preview' }, + { id: 'status' }, { id: 'actions' }, { id: 'action' }, + ], + states: [ + { id: 'editing', selector: { kind: 'self', suffix: '[data-bf-state~="editing"]' } }, + { id: 'sending', selector: { kind: 'self', suffix: '[data-bf-state~="sending"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.appearance.ts b/src/web-ui/src/flow_chat/components/RichTextInput.appearance.ts new file mode 100644 index 0000000000..cac563fe68 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/RichTextInput.appearance.ts @@ -0,0 +1,38 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const richTextInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'rich-text-input', + parts: [ + { id: 'root' }, + { id: 'contextTag' }, + { id: 'tagBadge' }, + { id: 'tagText' }, + { id: 'tagRemove' }, + ], + facets: [ + { + id: 'contextType', + attribute: 'data-bf-context-type', + values: [ + 'file', + 'directory', + 'session-reference', + 'code-snippet', + 'pull-request', + 'mermaid-node', + 'mermaid-diagram', + 'image', + 'terminal-command', + 'git-ref', + 'url', + 'web-element', + 'widget-reference', + 'skill-reference', + ], + }, + ], + states: [ + { id: 'focused', selector: { kind: 'self', suffix: '[data-bf-state~="focused"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/ScrollToBottomButton.appearance.ts b/src/web-ui/src/flow_chat/components/ScrollToBottomButton.appearance.ts new file mode 100644 index 0000000000..c0fc9f576b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ScrollToBottomButton.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const scrollToBottomButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'scroll-to-bottom-button', + parts: [{ id: 'root' }, { id: 'icon' }, { id: 'badge' }], +}; diff --git a/src/web-ui/src/flow_chat/components/ScrollToLatestBar.appearance.ts b/src/web-ui/src/flow_chat/components/ScrollToLatestBar.appearance.ts new file mode 100644 index 0000000000..908634c69e --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ScrollToLatestBar.appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const scrollToLatestBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'scroll-to-latest-bar', + parts: [{ id: 'root' }, { id: 'gradient' }, { id: 'content' }, { id: 'button' }], + facets: [{ id: 'input', attribute: 'data-bf-input', values: ['active', 'expanded', 'collapsed'] }], +}; diff --git a/src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.appearance.ts b/src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.appearance.ts new file mode 100644 index 0000000000..2368decd28 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const scrollToTurnHeaderButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'scroll-to-turn-header-button', + parts: [{ id: 'root' }, { id: 'gradient' }, { id: 'content' }, { id: 'button' }], + states: [{ id: 'visible', selector: { kind: 'self', suffix: '[data-bf-state~="visible"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/SnapshotRollbackButton.appearance.ts b/src/web-ui/src/flow_chat/components/SnapshotRollbackButton.appearance.ts new file mode 100644 index 0000000000..3ba11d201d --- /dev/null +++ b/src/web-ui/src/flow_chat/components/SnapshotRollbackButton.appearance.ts @@ -0,0 +1,8 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const snapshotRollbackButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'snapshot-rollback-button', + parts: [{ id: 'root' }, { id: 'label' }], + facets: [{ id: 'outcome', attribute: 'data-bf-outcome', values: ['idle', 'current', 'success', 'error'] }], + states: [{ id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.appearance.ts b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.appearance.ts new file mode 100644 index 0000000000..3f4bc2d016 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const stickyTaskIndicatorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'sticky-task-indicator', + parts: [ + { id: 'root' }, { id: 'gradient' }, { id: 'content' }, { id: 'button' }, + { id: 'icon' }, { id: 'label' }, { id: 'arrow' }, + ], + states: [{ id: 'visible', selector: { kind: 'self', suffix: '[data-bf-state~="visible"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.appearance.ts b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.appearance.ts new file mode 100644 index 0000000000..18c6933ba8 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const taskDetailPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'task-detail-panel', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'content' }, { id: 'empty' }, + { id: 'errorBanner' }, { id: 'reviewer' }, { id: 'prompt' }, + { id: 'actions' }, { id: 'error' }, { id: 'execution' }, { id: 'loading' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/TokenUsageIndicator.appearance.ts b/src/web-ui/src/flow_chat/components/TokenUsageIndicator.appearance.ts new file mode 100644 index 0000000000..89b79adbde --- /dev/null +++ b/src/web-ui/src/flow_chat/components/TokenUsageIndicator.appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const tokenUsageIndicatorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'token-usage-indicator', + parts: [{ id: 'root' }, { id: 'track' }, { id: 'fill' }, { id: 'hover' }, { id: 'percentage' }], + facets: [{ id: 'level', attribute: 'data-bf-level', values: ['normal', 'warning', 'critical'] }], +}; diff --git a/src/web-ui/src/flow_chat/components/ToolApprovalBar.appearance.ts b/src/web-ui/src/flow_chat/components/ToolApprovalBar.appearance.ts new file mode 100644 index 0000000000..fb00ead696 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ToolApprovalBar.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const toolApprovalBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tool-approval-bar', + parts: [{ id: 'root' }, { id: 'main' }, { id: 'message' }], +}; diff --git a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.appearance.ts b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.appearance.ts new file mode 100644 index 0000000000..bf4ceb90ae --- /dev/null +++ b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.appearance.ts @@ -0,0 +1,11 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const turnHistoryPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'turn-history-panel', + parts: [ + { id: 'root' }, { id: 'loading' }, { id: 'empty' }, { id: 'header' }, + { id: 'count' }, { id: 'list' }, { id: 'item' }, { id: 'itemHeader' }, + { id: 'files' }, { id: 'filesList' }, { id: 'time' }, + ], + states: [{ id: 'current', selector: { kind: 'self', suffix: '[data-bf-state~="current"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/TurnRollbackButton.appearance.ts b/src/web-ui/src/flow_chat/components/TurnRollbackButton.appearance.ts new file mode 100644 index 0000000000..24c889ab06 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/TurnRollbackButton.appearance.ts @@ -0,0 +1,8 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const turnRollbackButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'turn-rollback-button', + parts: [{ id: 'root' }], + facets: [{ id: 'mode', attribute: 'data-bf-mode', values: ['current', 'action'] }], + states: [{ id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/UserMessage.appearance.ts b/src/web-ui/src/flow_chat/components/UserMessage.appearance.ts new file mode 100644 index 0000000000..62cec8eba6 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/UserMessage.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const userMessageAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'user-message', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'inlineContent' }, + { id: 'footer' }, { id: 'timestamp' }, { id: 'snapshotAction' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.appearance.ts b/src/web-ui/src/flow_chat/components/WelcomePanel.appearance.ts new file mode 100644 index 0000000000..faa1a90abb --- /dev/null +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.appearance.ts @@ -0,0 +1,26 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const welcomePanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'welcome-panel', + parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'workspace' }, + { id: 'content', visualRole: 'card' }, + { id: 'greeting', visualRole: 'content' }, + { id: 'mascot', propertyProfile: 'paint', visualRole: 'decoration' }, + { id: 'heading', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'tagline', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'divider', propertyProfile: 'paint', visualRole: 'divider' }, + { id: 'narrative', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'openWorkspaceAction', propertyProfile: 'control', visualRole: 'control' }, + { id: 'workspaceAction', propertyProfile: 'control', visualRole: 'control' }, + { id: 'gitAction', propertyProfile: 'control', visualRole: 'control' }, + { id: 'workspaceMenu', propertyProfile: 'overlay', visualRole: 'popup' }, + { id: 'workspaceItem', propertyProfile: 'control', visualRole: 'control' }, + { id: 'cowork', visualRole: 'content' }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover:not(:disabled)' } }, + { id: 'focusVisible', selector: { kind: 'self', suffix: ':focus-visible' } }, + { id: 'disabled', selector: { kind: 'self', suffix: ':disabled' } }, + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/background-command/BackgroundCommandInputDialog.appearance.ts b/src/web-ui/src/flow_chat/components/background-command/BackgroundCommandInputDialog.appearance.ts new file mode 100644 index 0000000000..267e56ad55 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/background-command/BackgroundCommandInputDialog.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const backgroundCommandInputDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'background-command-input-dialog', + parts: [ + { id: 'root' }, { id: 'summary' }, { id: 'summaryLabel' }, { id: 'command' }, + { id: 'options' }, { id: 'note' }, { id: 'actions' }, + ], + states: [ + { id: 'sending', selector: { kind: 'self', suffix: '[data-bf-state~="sending"]' } }, + { id: 'masked', selector: { kind: 'self', suffix: '[data-bf-state~="masked"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/background-command/BackgroundCommandOutputPanel.appearance.ts b/src/web-ui/src/flow_chat/components/background-command/BackgroundCommandOutputPanel.appearance.ts new file mode 100644 index 0000000000..7bd275f6fa --- /dev/null +++ b/src/web-ui/src/flow_chat/components/background-command/BackgroundCommandOutputPanel.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const backgroundCommandOutputPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'background-command-output-panel', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'headerActions' }, + { id: 'meta' }, { id: 'notice' }, { id: 'error' }, { id: 'output' }, + { id: 'terminal' }, { id: 'empty' }, { id: 'inputEditor' }, + { id: 'inputOptions' }, { id: 'inputActions' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.appearance.ts b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.appearance.ts new file mode 100644 index 0000000000..1ec955c3e8 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.appearance.ts @@ -0,0 +1,26 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const btwSessionPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'btw-session-panel', + parts: [ + { id: 'root' }, + { id: 'header' }, + { id: 'headerMain' }, + { id: 'badge' }, + { id: 'title' }, + { id: 'meta' }, + { id: 'actions' }, + { id: 'body' }, + { id: 'empty' }, + { id: 'scrollToBottom' }, + { id: 'minimized' }, + { id: 'actionBar' }, + ], + facets: [ + { id: 'view', attribute: 'data-bf-view', values: ['empty', 'session'] }, + ], + states: [ + { id: 'hasActionBar', selector: { kind: 'self', suffix: '[data-bf-state~="hasActionBar"]' } }, + { id: 'minimized', selector: { kind: 'self', suffix: '[data-bf-state~="minimized"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.appearance.ts b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.appearance.ts new file mode 100644 index 0000000000..7fecbd2c3a --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const exploreGroupAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'explore-group', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'summary' }, + { id: 'contentWrapper' }, { id: 'content' }, { id: 'item' }, + ], + states: [{ id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.appearance.ts b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.appearance.ts new file mode 100644 index 0000000000..4900549200 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.appearance.ts @@ -0,0 +1,12 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const exportImageAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'export-image', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'logo' }, { id: 'title' }, + { id: 'timestamp' }, { id: 'user' }, { id: 'assistant' }, { id: 'round' }, + { id: 'text' }, { id: 'thinking' }, { id: 'tool' }, { id: 'footer' }, + { id: 'trigger' }, + ], + states: [{ id: 'exporting', selector: { kind: 'self', suffix: '[data-bf-state~="exporting"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.appearance.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.appearance.ts new file mode 100644 index 0000000000..713d97019d --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.appearance.ts @@ -0,0 +1,19 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const flowChatHeaderAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'flow-chat-header', + parts: [ + { id: 'root' }, + { id: 'leftActions' }, + { id: 'message' }, + { id: 'turnBadge' }, + { id: 'actions' }, + { id: 'backgroundActivity' }, + { id: 'activityPanel' }, + { id: 'activitySection' }, + { id: 'commandMenu' }, + { id: 'commandItem' }, + { id: 'search' }, + { id: 'searchControls' }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.appearance.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.appearance.ts new file mode 100644 index 0000000000..641b28545f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.appearance.ts @@ -0,0 +1,18 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const flowChatTurnRailAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'flow-chat-turn-rail', + parts: [ + { id: 'root' }, + { id: 'list' }, + { id: 'item' }, + { id: 'bar' }, + { id: 'tooltipContent' }, + { id: 'tooltipTurn' }, + { id: 'tooltipMessage' }, + ], + states: [ + { id: 'current', selector: { kind: 'self', suffix: '[data-bf-state~="current"]' } }, + { id: 'visible', selector: { kind: 'self', suffix: '[data-bf-state~="visible"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.appearance.ts b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.appearance.ts new file mode 100644 index 0000000000..51e98beeb9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.appearance.ts @@ -0,0 +1,34 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const modelRoundItemAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'model-round-item', + parts: [ + { id: 'root' }, + { id: 'retryHistory' }, + { id: 'retryToggle' }, + { id: 'retryAttempt' }, + { id: 'attemptLabel' }, + { id: 'diagnosticToggle' }, + { id: 'diagnosticDetails' }, + { id: 'diagnosticSection' }, + { id: 'subagent' }, + { id: 'toolItem' }, + { id: 'footer' }, + { id: 'meta' }, + { id: 'metaItem' }, + { id: 'action' }, + ], + facets: [ + { + id: 'status', + attribute: 'data-bf-status', + values: ['pending', 'queued', 'waiting', 'preparing', 'streaming', 'receiving', 'running', 'completed', 'error', 'cancelled', 'rejected', 'analyzing'], + }, + ], + states: [ + { id: 'streaming', selector: { kind: 'self', suffix: '[data-bf-state~="streaming"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'pending', selector: { kind: 'self', suffix: '[data-bf-state~="pending"]' } }, + { id: 'copied', selector: { kind: 'self', suffix: '[data-bf-state~="copied"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/PermissionRequestPanel.appearance.ts b/src/web-ui/src/flow_chat/components/modern/PermissionRequestPanel.appearance.ts new file mode 100644 index 0000000000..dee115f839 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/PermissionRequestPanel.appearance.ts @@ -0,0 +1,17 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const permissionRequestPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'permission-request-panel', + parts: [ + { id: 'root' }, { id: 'collapsedTrigger' }, { id: 'collapsedBadge' }, + { id: 'panel' }, { id: 'heading' }, { id: 'headingActions' }, + { id: 'requests' }, { id: 'request' }, { id: 'requestHeading' }, + { id: 'requestDetails' }, { id: 'risk' }, { id: 'error' }, + { id: 'feedback' }, { id: 'actions' }, { id: 'singleActions' }, { id: 'batchActions' }, + ], + states: [ + { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, + { id: 'responding', selector: { kind: 'self', suffix: '[data-bf-state~="responding"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.appearance.ts b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.appearance.ts new file mode 100644 index 0000000000..30c03cb9cd --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const runtimeStatusSlotAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'runtime-status-slot', + parts: [{ id: 'root' }, { id: 'content' }, { id: 'hint' }], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.appearance.ts b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.appearance.ts new file mode 100644 index 0000000000..4573cb832b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.appearance.ts @@ -0,0 +1,18 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionFileModificationsBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session-file-modifications-bar', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'summary' }, { id: 'toggle' }, + { id: 'list' }, { id: 'file' }, { id: 'fileIcon' }, { id: 'fileName' }, + { id: 'fileSource' }, { id: 'fileError' }, { id: 'fileStats' }, + ], + facets: [ + { id: 'layout', attribute: 'data-bf-layout', values: ['default', 'compact'] }, + { id: 'operation', attribute: 'data-bf-operation', values: ['write', 'delete', 'edit'] }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.appearance.ts b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.appearance.ts new file mode 100644 index 0000000000..804e2f36d3 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.appearance.ts @@ -0,0 +1,29 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionFilesBadgeAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session-files-badge', + parts: [ + { id: 'root' }, + { id: 'reviewMenu' }, + { id: 'reviewTrigger' }, + { id: 'reviewPopover' }, + { id: 'reviewItem' }, + { id: 'trigger' }, + { id: 'stats' }, + { id: 'popover' }, + { id: 'summary' }, + { id: 'list' }, + { id: 'file' }, + { id: 'fileError' }, + { id: 'fileStats' }, + ], + facets: [ + { id: 'operation', attribute: 'data-bf-operation', values: ['create', 'modify', 'delete'] }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/TurnCompletionNoticeItem.appearance.ts b/src/web-ui/src/flow_chat/components/modern/TurnCompletionNoticeItem.appearance.ts new file mode 100644 index 0000000000..0ac97167c8 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnCompletionNoticeItem.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const turnCompletionNoticeAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'turn-completion-notice', + parts: [ + { id: 'root' }, { id: 'icon' }, { id: 'content' }, { id: 'text' }, + { id: 'title' }, { id: 'body' }, { id: 'reason' }, { id: 'reasonCode' }, + ], + facets: [{ id: 'tone', attribute: 'data-bf-tone', values: ['error', 'info', 'warning'] }], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.appearance.ts b/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.appearance.ts new file mode 100644 index 0000000000..cb1a753de4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const turnFailureNoticeAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'turn-failure-notice', + parts: [ + { id: 'root' }, { id: 'icon' }, { id: 'content' }, { id: 'header' }, + { id: 'summary' }, { id: 'title' }, { id: 'message' }, { id: 'toggle' }, + { id: 'details' }, { id: 'facts' }, { id: 'fact' }, { id: 'rawError' }, + { id: 'rawHeader' }, { id: 'copy' }, { id: 'code' }, + ], + states: [ + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'copied', selector: { kind: 'self', suffix: '[data-bf-state~="copied"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.appearance.ts b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.appearance.ts new file mode 100644 index 0000000000..ab98a6b5e7 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const userMessageEditComposerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'user-message-edit-composer', + parts: [{ id: 'root' }, { id: 'input' }, { id: 'actions' }, { id: 'action' }, { id: 'spinner' }], + facets: [ + { id: 'mode', attribute: 'data-bf-mode', values: ['rich', 'plain'] }, + { id: 'action', attribute: 'data-bf-action', values: ['cancel', 'submit'] }, + ], + states: [ + { id: 'submitting', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="submitting"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.appearance.ts b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.appearance.ts new file mode 100644 index 0000000000..0765d6bf24 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const userMessageItemAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'user-message-item', + parts: [ + { id: 'root' }, { id: 'timestamp' }, { id: 'main' }, { id: 'content' }, + { id: 'steeringTag' }, { id: 'actions' }, { id: 'images' }, { id: 'image' }, + { id: 'blocks' }, { id: 'lightbox' }, { id: 'loading' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.appearance.ts b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.appearance.ts new file mode 100644 index 0000000000..197631650b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const virtualItemAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'virtual-item', + parts: [{ id: 'root' }, { id: 'imageAnalyzing' }, { id: 'placeholder' }], + states: [ + { id: 'searchMatch', selector: { kind: 'self', suffix: '[data-bf-state~="searchMatch"]' } }, + { id: 'searchCurrent', selector: { kind: 'self', suffix: '[data-bf-state~="searchCurrent"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/modern/appearance.ts b/src/web-ui/src/flow_chat/components/modern/appearance.ts new file mode 100644 index 0000000000..91360fb56b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/appearance.ts @@ -0,0 +1,38 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const virtualMessageListAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'virtual-message-list', + parts: [ + { id: 'root' }, + { id: 'empty' }, + { id: 'emptyMessage' }, + { id: 'scroller' }, + { id: 'header' }, + { id: 'boundaryStatus' }, + { id: 'spacer' }, + { id: 'items' }, + { id: 'footer' }, + { id: 'handoffOverlay' }, + { id: 'handoffContent' }, + ], + facets: [ + { id: 'renderMode', attribute: 'data-bf-render-mode', values: ['static', 'virtual'] }, + { id: 'position', attribute: 'data-bf-position', values: ['leading', 'trailing'] }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + { id: 'preparing', selector: { kind: 'self', suffix: '[data-bf-state~="preparing"]' } }, + { id: 'unavailable', selector: { kind: 'self', suffix: '[data-bf-state~="unavailable"]' } }, + ], +}; + +export const modernFlowChatAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'modern-flow-chat', + parts: [ + { id: 'root' }, + { id: 'messages' }, + { id: 'historyOverlay' }, + { id: 'historyOpenIntent' }, + { id: 'historyOpenIntentSpinner' }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.appearance.ts b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.appearance.ts new file mode 100644 index 0000000000..5764cbabe9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.appearance.ts @@ -0,0 +1,18 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionMenuAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session-menu', + parts: [ + { id: 'root' }, { id: 'trigger' }, { id: 'dropdown' }, { id: 'actions' }, + { id: 'scroll' }, { id: 'divider' }, { id: 'item' }, { id: 'itemIcon' }, + { id: 'itemLabel' }, + ], + facets: [ + { id: 'itemKind', attribute: 'data-bf-item-kind', values: ['create', 'session'] }, + { id: 'sessionKind', attribute: 'data-bf-session-kind', values: ['code', 'cowork'] }, + ], + states: [ + { id: 'open', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="open"]' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/smart-recommendations/SmartRecommendations.appearance.ts b/src/web-ui/src/flow_chat/components/smart-recommendations/SmartRecommendations.appearance.ts new file mode 100644 index 0000000000..5a158702a9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/smart-recommendations/SmartRecommendations.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const smartRecommendationsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'smart-recommendations', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'close' }, + { id: 'actions' }, { id: 'action' }, { id: 'label' }, { id: 'loading' }, + ], + states: [{ id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.appearance.ts b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.appearance.ts new file mode 100644 index 0000000000..cf9c94f54e --- /dev/null +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const subagentProjectionAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'subagent-projection', + parts: [ + { id: 'root' }, { id: 'truncated' }, { id: 'hint' }, { id: 'message' }, + { id: 'expandAction' }, { id: 'item' }, { id: 'collapse' }, + { id: 'container' }, { id: 'content' }, + ], + states: [ + { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/thread-goal/ThreadGoalDialogs.appearance.ts b/src/web-ui/src/flow_chat/components/thread-goal/ThreadGoalDialogs.appearance.ts new file mode 100644 index 0000000000..ef87964bcf --- /dev/null +++ b/src/web-ui/src/flow_chat/components/thread-goal/ThreadGoalDialogs.appearance.ts @@ -0,0 +1,18 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const threadGoalDialogsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'thread-goal-dialogs', + parts: [ + { id: 'menu', propertyProfile: 'overlay', visualRole: 'dialog' }, + { id: 'header', visualRole: 'toolbar', continuityGroup: 'thread-goal-dialog' }, + { id: 'section', visualRole: 'content' }, + { id: 'objective', visualRole: 'content' }, + { id: 'workflow', visualRole: 'content' }, + { id: 'workflowItem', propertyProfile: 'control', visualRole: 'control' }, + { id: 'footer', visualRole: 'toolbar', continuityGroup: 'thread-goal-dialog' }, + { id: 'actions', visualRole: 'toolbar' }, + { id: 'hint', propertyProfile: 'paint', visualRole: 'content' }, + { id: 'edit', propertyProfile: 'control', visualRole: 'control' }, + { id: 'resume', propertyProfile: 'control', visualRole: 'control' }, + ], + states: [{ id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.appearance.ts b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.appearance.ts new file mode 100644 index 0000000000..f47f1cf1f4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.appearance.ts @@ -0,0 +1,24 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const toolbarModeAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'toolbar-mode', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'headerLeft' }, { id: 'title' }, + { id: 'headerActions' }, { id: 'overflowTrigger' }, { id: 'overflowMenu' }, + { id: 'overflowItem' }, { id: 'collapsedActions' }, { id: 'sessionSurface' }, + { id: 'content' }, { id: 'stream' }, { id: 'tool' }, { id: 'toolName' }, + { id: 'toolSummary' }, { id: 'todo' }, { id: 'todoProgress' }, + { id: 'todoCurrent' }, { id: 'streamText' }, { id: 'controls' }, + ], + facets: [ + { id: 'contentKind', attribute: 'data-bf-content-kind', values: ['text', 'tool', 'todo'] }, + ], + states: [ + { id: 'expanded', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'processing', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="processing"]' } }, + { id: 'error', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="error"]' } }, + { id: 'confirm', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="confirm"]' } }, + { id: 'streaming', selector: { kind: 'self', suffix: '[data-bf-state~="streaming"]' } }, + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/usage/SessionRuntimeStatusEntry.appearance.ts b/src/web-ui/src/flow_chat/components/usage/SessionRuntimeStatusEntry.appearance.ts new file mode 100644 index 0000000000..fb36aa44e2 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/SessionRuntimeStatusEntry.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionRuntimeStatusEntryAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session-runtime-status-entry', + parts: [{ id: 'root' }, { id: 'icon' }, { id: 'label' }], +}; diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.appearance.ts b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.appearance.ts new file mode 100644 index 0000000000..0c92c020ce --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionUsageReportCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session-usage-report-card', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'actions' }, + { id: 'metrics' }, { id: 'metric' }, { id: 'lists' }, { id: 'list' }, + { id: 'listRow' }, { id: 'fileStat' }, { id: 'loading' }, { id: 'fallback' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'fallback', selector: { kind: 'self', suffix: '[data-bf-state~="fallback"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/usage/appearance.ts b/src/web-ui/src/flow_chat/components/usage/appearance.ts new file mode 100644 index 0000000000..ddac13e3b8 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/appearance.ts @@ -0,0 +1,24 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionUsagePanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session-usage-panel', + parts: [ + { id: 'root' }, + { id: 'header' }, + { id: 'tabs' }, + { id: 'tab' }, + { id: 'body' }, + { id: 'metaRow' }, + { id: 'section' }, + { id: 'metric' }, + { id: 'table' }, + { id: 'empty' }, + ], + facets: [ + { id: 'tab', attribute: 'data-bf-tab', values: ['overview', 'models', 'tools', 'files', 'errors', 'slowest'] }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'fallback', selector: { kind: 'self', suffix: '[data-bf-state~="fallback"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/voice/ComposerVoiceInputButton.appearance.ts b/src/web-ui/src/flow_chat/components/voice/ComposerVoiceInputButton.appearance.ts new file mode 100644 index 0000000000..8841711931 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/voice/ComposerVoiceInputButton.appearance.ts @@ -0,0 +1,19 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const composerVoiceInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'composer-voice-input', + parts: [ + { id: 'root' }, { id: 'control' }, { id: 'pill' }, { id: 'status' }, + { id: 'time' }, { id: 'timeline' }, { id: 'timelineBar' }, { id: 'divider' }, + { id: 'action' }, + ], + facets: [ + { id: 'phase', attribute: 'data-bf-phase', values: ['idle', 'preparing', 'recording', 'transcribing'] }, + { id: 'action', attribute: 'data-bf-action', values: ['cancel', 'transcribe', 'send'] }, + ], + states: [ + { id: 'active', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="active"]' } }, + { id: 'lowVolume', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="low-volume"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/appearance.ts b/src/web-ui/src/flow_chat/deep-review/action-bar/appearance.ts new file mode 100644 index 0000000000..63692b063d --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/appearance.ts @@ -0,0 +1,41 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const deepReviewActionBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'deep-review-action-bar', + parts: [ + { id: 'root' }, + { id: 'progress' }, + { id: 'attribution' }, + { id: 'degradation' }, + { id: 'degradationOption' }, + { id: 'noIssues' }, + { id: 'fixDone' }, + { id: 'custom' }, + { id: 'customInput' }, + ], + facets: [ + { + id: 'phase', + attribute: 'data-bf-phase', + values: [ + 'review_running', + 'review_completed', + 'fix_running', + 'fix_completed', + 'fix_failed', + 'fix_timeout', + 'fix_interrupted', + 'review_waiting_capacity', + 'review_interrupted', + 'resume_blocked', + 'resume_running', + 'resume_failed', + 'review_error', + ], + }, + { id: 'variant', attribute: 'data-bf-variant', values: ['success', 'warning', 'error', 'info', 'loading'] }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.appearance.ts new file mode 100644 index 0000000000..8d11a45e28 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.appearance.ts @@ -0,0 +1,11 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const acpPermissionActionsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'acp-permission-actions', + parts: [{ id: 'root' }, { id: 'action' }], + facets: [ + { id: 'presentation', attribute: 'data-bf-presentation', values: ['icon', 'text'] }, + { id: 'decision', attribute: 'data-bf-decision', values: ['allow', 'reject'] }, + ], + states: [{ id: 'disabled', selector: { kind: 'self', suffix: ':disabled' } }], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.appearance.ts new file mode 100644 index 0000000000..59dd38d247 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const askUserQuestionCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'ask-user-question-card', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'questions' }, { id: 'question' }, + { id: 'options' }, { id: 'option' }, { id: 'customInput' }, { id: 'footer' }, + { id: 'status' }, { id: 'summary' }, { id: 'loading' }, { id: 'error' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'completed', selector: { kind: 'self', suffix: '[data-bf-state~="completed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.appearance.ts new file mode 100644 index 0000000000..26c94a8d5b --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.appearance.ts @@ -0,0 +1,25 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const toolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tool-card', + parts: [ + { id: 'root' }, { id: 'surface' }, { id: 'header' }, { id: 'icon' }, + { id: 'action' }, { id: 'content' }, { id: 'extra' }, { id: 'status' }, + { id: 'expanded' }, { id: 'error' }, + ], + facets: [{ + id: 'status', + attribute: 'data-bf-status', + values: [ + 'pending', 'queued', 'waiting', 'preparing', 'streaming', 'receiving', + 'running', 'completed', 'error', 'cancelled', 'rejected', 'analyzing', + 'pending_confirmation', 'confirmed', + ], + }], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'confirmation', selector: { kind: 'self', suffix: '[data-bf-state~="confirmation"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/CanvasToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/CanvasToolCard.appearance.ts new file mode 100644 index 0000000000..48ca5c63fe --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/CanvasToolCard.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const canvasToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'canvas-tool-card', + parts: [ + { id: 'root' }, { id: 'title' }, { id: 'extra' }, { id: 'diagnostics' }, + { id: 'status' }, { id: 'body' }, { id: 'sourcePreview' }, { id: 'meta' }, + { id: 'diagnosticList' }, + ], + states: [ + { id: 'clickable', selector: { kind: 'self', suffix: '[data-bf-state~="clickable"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.appearance.ts new file mode 100644 index 0000000000..ce790c7989 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.appearance.ts @@ -0,0 +1,24 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const codeReviewToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'code-review-tool-card', + parts: [ + { id: 'root' }, + { id: 'details' }, + { id: 'reliability' }, + { id: 'reliabilityItem' }, + { id: 'summary' }, + { id: 'issues' }, + { id: 'issue' }, + { id: 'remediation' }, + { id: 'groups' }, + { id: 'group' }, + { id: 'decision' }, + { id: 'remediationList' }, + { id: 'remediationItem' }, + { id: 'positive' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.appearance.ts new file mode 100644 index 0000000000..84aa6fbdd4 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.appearance.ts @@ -0,0 +1,23 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const compactToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'compact-tool-card', + parts: [ + { id: 'root' }, { id: 'surface' }, { id: 'expanded' }, + { id: 'action' }, { id: 'content' }, { id: 'extra' }, + ], + facets: [{ + id: 'status', + attribute: 'data-bf-status', + values: [ + 'pending', 'queued', 'waiting', 'preparing', 'streaming', 'receiving', + 'running', 'completed', 'error', 'cancelled', 'rejected', 'analyzing', + 'pending_confirmation', 'confirmed', + ], + }], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'clickable', selector: { kind: 'self', suffix: '[data-bf-state~="clickable"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.appearance.ts new file mode 100644 index 0000000000..78aa27f4c6 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const computerUseToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'computer-use-tool-card', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'permissionDenied' }, + { id: 'settingsButton' }, { id: 'expanded' }, { id: 'row' }, + { id: 'loopWarning' }, { id: 'actionCode' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.appearance.ts new file mode 100644 index 0000000000..15d48ce0c0 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const contextCompressionDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'context-compression-display', + parts: [ + { id: 'root' }, { id: 'info' }, { id: 'tokenStat' }, { id: 'savings' }, + { id: 'processing' }, { id: 'meta' }, { id: 'detail' }, { id: 'error' }, + ], + states: [ + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + { id: 'fallback', selector: { kind: 'self', suffix: '[data-bf-state~="fallback"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.appearance.ts new file mode 100644 index 0000000000..44ddaf4e5c --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const createPlanDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'create-plan-display', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'headerMain' }, { id: 'content' }, + { id: 'overview' }, { id: 'todos' }, { id: 'todo' }, { id: 'footer' }, + { id: 'loading' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.appearance.ts new file mode 100644 index 0000000000..29d6aa1bff --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const defaultToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'default-tool-card', + parts: [ + { id: 'root' }, { id: 'expanded' }, { id: 'meta' }, { id: 'metaLabel' }, + { id: 'metaDescription' }, { id: 'section' }, { id: 'sectionLabel' }, + { id: 'code' }, { id: 'error' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'confirmation', selector: { kind: 'self', suffix: '[data-bf-state~="confirmation"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.appearance.ts new file mode 100644 index 0000000000..70df558767 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const execProcessToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'exec-process-tool-card', + parts: [ + { id: 'root' }, { id: 'status' }, { id: 'actions' }, { id: 'output' }, + { id: 'result' }, { id: 'footer' }, { id: 'waiting' }, { id: 'error' }, + ], + states: [ + { id: 'cancelled', selector: { kind: 'self', suffix: '[data-bf-state~="cancelled"]' } }, + { id: 'waiting', selector: { kind: 'self', suffix: '[data-bf-state~="waiting"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.appearance.ts new file mode 100644 index 0000000000..559318d1cc --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.appearance.ts @@ -0,0 +1,21 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const fileOperationToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'file-operation-tool-card', + parts: [ + { id: 'root' }, + { id: 'preview' }, + { id: 'previewText' }, + { id: 'guidance' }, + { id: 'error' }, + { id: 'action' }, + { id: 'path' }, + ], + facets: [ + { id: 'action', attribute: 'data-bf-action', values: ['create', 'modify', 'delete'] }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance.ts new file mode 100644 index 0000000000..adfccbf490 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const generativeWidgetToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'generative-widget-tool-card', + parts: [ + { id: 'root' }, { id: 'title' }, { id: 'extra' }, { id: 'status' }, + { id: 'exportAction' }, { id: 'placeholder' }, { id: 'preview' }, + { id: 'captureRoot' }, { id: 'exportStage' }, + ], + states: [ + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + { id: 'exporting', selector: { kind: 'self', suffix: '[data-bf-state~="exporting"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.appearance.ts new file mode 100644 index 0000000000..f70b1725a8 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const getFileDiffDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'get-file-diff-display', + parts: [ + { id: 'root' }, { id: 'info' }, { id: 'fileName' }, { id: 'diffType' }, + { id: 'stats' }, { id: 'expanded' }, { id: 'message' }, { id: 'preview' }, + { id: 'error' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.appearance.ts new file mode 100644 index 0000000000..17332b695a --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const getToolSpecCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'get-tool-spec-card', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'expanded' }, + { id: 'section' }, { id: 'label' }, { id: 'description' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.appearance.ts new file mode 100644 index 0000000000..b76e4fb0c2 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const gitToolDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'git-tool-display', + parts: [ + { id: 'root' }, { id: 'headerExtra' }, { id: 'info' }, { id: 'result' }, + { id: 'output' }, { id: 'stderr' }, { id: 'footer' }, { id: 'error' }, + ], + states: [{ id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.appearance.ts new file mode 100644 index 0000000000..6d2994e834 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const mcpToolDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'mcp-tool-display', + parts: [ + { id: 'root' }, { id: 'info' }, { id: 'expanded' }, + { id: 'item' }, { id: 'text' }, { id: 'image' }, { id: 'resource' }, + { id: 'iframe' }, { id: 'loading' }, { id: 'error' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.appearance.ts new file mode 100644 index 0000000000..94fc0e9a85 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const miniAppToolDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'mini-app-tool-display', + parts: [ + { id: 'root' }, { id: 'info' }, { id: 'operation' }, { id: 'command' }, + { id: 'output' }, { id: 'errorIndicator' }, { id: 'result' }, { id: 'rows' }, + { id: 'row' }, { id: 'label' }, { id: 'value' }, { id: 'footer' }, + { id: 'open' }, { id: 'error' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.appearance.ts new file mode 100644 index 0000000000..9b2310b4e1 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const modelThinkingDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'model-thinking-display', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'chevron' }, { id: 'label' }, + { id: 'expandContainer' }, { id: 'contentWrapper' }, { id: 'content' }, + ], + facets: [{ id: 'context', attribute: 'data-bf-context', values: ['default', 'subagent-projection'] }], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'streaming', selector: { kind: 'self', suffix: '[data-bf-state~="streaming"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.appearance.ts new file mode 100644 index 0000000000..3feffa42a3 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const reviewSessionSummaryCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'review-session-summary-card', + parts: [ + { id: 'fileCount' }, { id: 'details' }, { id: 'summary' }, + { id: 'files' }, { id: 'open' }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.appearance.ts new file mode 100644 index 0000000000..d5118b4952 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.appearance.ts @@ -0,0 +1,11 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const snapshotFullscreenDiffViewerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'snapshot-fullscreen-diff-viewer', + parts: [ + { id: 'overlay' }, { id: 'root' }, { id: 'header' }, { id: 'sessionInfo' }, + { id: 'headerActions' }, { id: 'navigation' }, { id: 'tabs' }, { id: 'tab' }, + { id: 'currentFile' }, { id: 'fileInfo' }, { id: 'fileActions' }, + { id: 'content' }, { id: 'loading' }, + ], + states: [{ id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.appearance.ts new file mode 100644 index 0000000000..0ca1e18ccf --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const taskToolDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'task-tool-display', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'body' }, { id: 'main' }, + { id: 'action' }, { id: 'meta' }, { id: 'rail' }, { id: 'expanded' }, + { id: 'interruption' }, { id: 'reviewer' }, { id: 'responsibilities' }, + { id: 'prompt' }, { id: 'cancel' }, + ], + states: [ + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.appearance.ts new file mode 100644 index 0000000000..29b8707509 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.appearance.ts @@ -0,0 +1,26 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const terminalToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'terminal-tool-card', + parts: [ + { id: 'root' }, { id: 'status' }, { id: 'actions' }, + { id: 'output' }, { id: 'waiting' }, { id: 'result' }, + { id: 'footer' }, { id: 'error' }, + ], + facets: [{ + id: 'status', + attribute: 'data-bf-status', + values: [ + 'pending', 'queued', 'waiting', 'preparing', 'streaming', 'receiving', + 'running', 'completed', 'error', 'cancelled', 'rejected', + 'pending_confirmation', 'confirmed', + ], + }], + states: [ + { id: 'running', selector: { kind: 'self', suffix: '[data-bf-state~="running"]' } }, + { id: 'completed', selector: { kind: 'self', suffix: '[data-bf-state~="completed"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'cancelled', selector: { kind: 'self', suffix: '[data-bf-state~="cancelled"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.appearance.ts new file mode 100644 index 0000000000..ee3cca95c9 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const todoWriteDisplayAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'todo-write-display', + parts: [ + { id: 'root' }, { id: 'compactIcon' }, { id: 'count' }, { id: 'progress' }, + { id: 'headerContent' }, { id: 'expanded' }, { id: 'list' }, + { id: 'item' }, { id: 'itemContent' }, + ], + facets: [{ id: 'mode', attribute: 'data-bf-mode', values: ['compact', 'standard'] }], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'completed', selector: { kind: 'self', suffix: '[data-bf-state~="completed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolCardHeaderActions.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ToolCardHeaderActions.appearance.ts new file mode 100644 index 0000000000..e7c8b1f92a --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ToolCardHeaderActions.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const toolCardHeaderActionsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tool-card-header-actions', + parts: [{ id: 'root' }, { id: 'action' }], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.appearance.ts new file mode 100644 index 0000000000..bf3ba24de2 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.appearance.ts @@ -0,0 +1,7 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const toolCardStatusSlotAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tool-card-status-slot', + parts: [{ id: 'root' }, { id: 'statusLayer' }, { id: 'iconLayer' }], + facets: [{ id: 'defaultIcon', attribute: 'data-bf-default-icon', values: ['status', 'tool'] }], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.appearance.ts new file mode 100644 index 0000000000..1ce326e6fe --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const toolTimeoutIndicatorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tool-timeout-indicator', + parts: [ + { id: 'root' }, { id: 'duration' }, { id: 'elapsed' }, { id: 'timeout' }, + { id: 'controls' }, { id: 'toggle' }, { id: 'popover' }, { id: 'option' }, + ], + facets: [{ id: 'mode', attribute: 'data-bf-mode', values: ['completed', 'live'] }], + states: [ + { id: 'warning', selector: { kind: 'self', suffix: '[data-bf-state~="warning"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.appearance.ts new file mode 100644 index 0000000000..cbe4bd3c5f --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const viewImageToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'view-image-tool-card', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'error' }, { id: 'preview' }, + { id: 'image' }, { id: 'lightbox' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/WebFetchCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/WebFetchCard.appearance.ts new file mode 100644 index 0000000000..6c1faf6edf --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/WebFetchCard.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const webFetchCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'web-fetch-card', + parts: [ + { id: 'root' }, { id: 'headerUrl' }, { id: 'content' }, { id: 'expanded' }, + { id: 'meta' }, { id: 'detailsRow' }, { id: 'details' }, { id: 'detail' }, + { id: 'actions' }, { id: 'result' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/appearance/adapters/CanvasAppearanceAdapter.ts b/src/web-ui/src/infrastructure/appearance/adapters/CanvasAppearanceAdapter.ts new file mode 100644 index 0000000000..b8288dede1 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/CanvasAppearanceAdapter.ts @@ -0,0 +1,51 @@ +import type { AppearanceRendererAdapter, AppearanceRendererContext, CanvasAppearanceSettings } from '../types'; + +const COLOR_PATTERN = /^(?:transparent|#[0-9a-f]{3,8}|rgba?\(\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)|hsla?\([^)]+\))$/i; +const COLOR_KEYS = ['bg', 'panel', 'fg', 'muted', 'border', 'accent', 'success', 'warning', 'danger', 'info'] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export class CanvasAppearanceAdapter implements AppearanceRendererAdapter<'bitfun-canvas'> { + readonly id = 'bitfun-canvas'; + private settings: CanvasAppearanceSettings | null = null; + private listeners = new Set<() => void>(); + + validate(settings: unknown): string[] { + const errors: string[] = []; + if (!isRecord(settings)) return ['Settings must be an object']; + const known = new Set(['id', 'mode', ...COLOR_KEYS]); + Object.keys(settings).forEach(key => { + if (!known.has(key)) errors.push(`Unknown setting: ${key}`); + }); + if (typeof settings.id !== 'string' || !/^[a-z][a-z0-9.-]{0,95}$/.test(settings.id)) errors.push('id is invalid'); + if (!['light', 'dark'].includes(String(settings.mode))) errors.push('mode must be light or dark'); + COLOR_KEYS.forEach(key => { + const value = settings[key]; + if (typeof value !== 'string' || !COLOR_PATTERN.test(value)) errors.push(`${key} is not a supported color`); + }); + return errors; + } + + apply(next: Readonly | undefined, _previous: Readonly | undefined, _context: AppearanceRendererContext): void { + if (next) { + const errors = this.validate(next); + if (errors.length > 0) throw new Error(`Invalid Canvas appearance: ${errors.join('; ')}`); + this.settings = next as CanvasAppearanceSettings; + } else this.settings = null; + this.listeners.forEach(listener => listener()); + } + + getSettings(): CanvasAppearanceSettings { + if (!this.settings) throw new Error('Canvas appearance has not been applied'); + return this.settings; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +export const canvasAppearanceAdapter = new CanvasAppearanceAdapter(); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/CssTokenAppearanceAdapter.ts b/src/web-ui/src/infrastructure/appearance/adapters/CssTokenAppearanceAdapter.ts new file mode 100644 index 0000000000..85ced3c713 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/CssTokenAppearanceAdapter.ts @@ -0,0 +1,62 @@ +import type { AppearanceRendererAdapter, CssTokenAppearanceSettings } from '../types'; +import { APPEARANCE_CSS_TOKEN_NAMES } from '../builtins/catalog'; + +const ALLOWED_TOKEN_NAMES = new Set(APPEARANCE_CSS_TOKEN_NAMES); + +const FORBIDDEN_VALUE = /(?:url\s*\(|var\s*\(|expression\s*\(|[;{}<>])/i; + +function isSettings(value: unknown): value is CssTokenAppearanceSettings { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return Boolean( + record.tokens + && typeof record.tokens === 'object' + && !Array.isArray(record.tokens) + && typeof record.background === 'string', + ); +} + +function validateToken(name: string, value: unknown): string | null { + if (!ALLOWED_TOKEN_NAMES.has(name)) { + return `Unsupported CSS token name: ${name}`; + } + if (typeof value !== 'string' || value.length === 0 || value.length > 512) { + return `CSS token ${name} must be a non-empty string of at most 512 characters`; + } + if (FORBIDDEN_VALUE.test(value)) { + return `CSS token ${name} contains a forbidden value`; + } + return null; +} + +function removeTokens(settings: Readonly | undefined): void { + if (!isSettings(settings)) return; + const rootStyle = document.documentElement.style; + Object.keys(settings.tokens).forEach(name => rootStyle.removeProperty(name)); +} + +export const cssTokenAppearanceAdapter: AppearanceRendererAdapter<'css-tokens'> = { + id: 'css-tokens', + validate(settings) { + if (!isSettings(settings)) return ['css-tokens settings must contain tokens and background']; + const errors = Object.entries(settings.tokens) + .map(([name, value]) => validateToken(name, value)) + .filter((error): error is string => error !== null); + if (FORBIDDEN_VALUE.test(settings.background)) { + errors.push('css-tokens background contains a forbidden value'); + } + return errors; + }, + apply(next, previous) { + removeTokens(previous); + if (!isSettings(next)) { + document.documentElement.style.backgroundColor = ''; + if (document.body) document.body.style.backgroundColor = ''; + return; + } + const rootStyle = document.documentElement.style; + Object.entries(next.tokens).forEach(([name, value]) => rootStyle.setProperty(name, value)); + rootStyle.backgroundColor = next.background; + if (document.body) document.body.style.backgroundColor = next.background; + }, +}; diff --git a/src/web-ui/src/infrastructure/appearance/adapters/MermaidAppearanceAdapter.ts b/src/web-ui/src/infrastructure/appearance/adapters/MermaidAppearanceAdapter.ts new file mode 100644 index 0000000000..57a6678b99 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/MermaidAppearanceAdapter.ts @@ -0,0 +1,79 @@ +import type { AppearanceRendererAdapter, AppearanceRendererContext, MermaidAppearanceSettings } from '../types'; + +const COLOR_PATTERN = /^(?:transparent|#[0-9a-f]{3,8}|rgba?\(\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/i; +const PALETTE_KEYS = [ + 'nodeFill', 'nodeFillHover', 'nodeText', 'nodeStroke', 'nodeStrokeHover', + 'clusterFill', 'clusterText', 'clusterStroke', 'edgeStroke', 'edgeLabelBackground', + 'edgeLabelText', 'noteFill', 'noteText', 'noteStroke', 'activationFill', + 'activationStroke', 'success', 'warning', 'error', 'errorBackground', 'info', + 'highlight', 'pieColors', +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export class MermaidAppearanceAdapter implements AppearanceRendererAdapter<'mermaid'> { + readonly id = 'mermaid'; + private settings: MermaidAppearanceSettings | null = null; + private revision = 0; + private listeners = new Set<() => void>(); + + validate(settings: unknown): string[] { + const errors: string[] = []; + if (!isRecord(settings)) return ['Settings must be an object']; + Object.keys(settings).forEach(key => { + if (!['mode', 'palette'].includes(key)) errors.push(`Unknown setting: ${key}`); + }); + if (!['light', 'dark'].includes(String(settings.mode))) errors.push('mode must be light or dark'); + if (!isRecord(settings.palette)) { + errors.push('palette must be an object'); + return errors; + } + Object.keys(settings.palette).forEach(key => { + if (!PALETTE_KEYS.includes(key as typeof PALETTE_KEYS[number])) errors.push(`palette.${key} is not supported`); + }); + const palette = settings.palette as Record; + PALETTE_KEYS.forEach(key => { + const value = palette[key]; + if (key === 'pieColors') { + if (!Array.isArray(value) || value.length !== 8 || value.some(item => typeof item !== 'string' || !COLOR_PATTERN.test(item))) { + errors.push('palette.pieColors must contain exactly eight supported colors'); + } + } else if (typeof value !== 'string' || !COLOR_PATTERN.test(value)) { + errors.push(`palette.${key} is not a supported color`); + } + }); + return errors; + } + + apply( + next: Readonly | undefined, + _previous: Readonly | undefined, + context: AppearanceRendererContext, + ): void { + if (next) { + const errors = this.validate(next); + if (errors.length > 0) throw new Error(`Invalid Mermaid appearance: ${errors.join('; ')}`); + this.settings = next as MermaidAppearanceSettings; + } else this.settings = null; + this.revision = context.revision; + this.listeners.forEach(listener => listener()); + } + + getSettings(): MermaidAppearanceSettings { + if (!this.settings) throw new Error('Mermaid appearance has not been applied'); + return this.settings; + } + + getRevision(): number { + return this.revision; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +export const mermaidAppearanceAdapter = new MermaidAppearanceAdapter(); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/MonacoAppearanceAdapter.ts b/src/web-ui/src/infrastructure/appearance/adapters/MonacoAppearanceAdapter.ts new file mode 100644 index 0000000000..994f03c9ca --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/MonacoAppearanceAdapter.ts @@ -0,0 +1,150 @@ +import type * as Monaco from 'monaco-editor'; +import { createLogger } from '@/shared/utils/logger'; +import type { + AppearanceRendererAdapter, + AppearanceRendererContext, + MonacoAppearanceSettings, +} from '../types'; + +const log = createLogger('MonacoAppearanceAdapter'); + +export interface MonacoAppearanceChangeEvent { + previousThemeId: string | null; + currentThemeId: string; + revision: number; +} + +type MonacoModule = typeof Monaco; +type MonacoAppearanceListener = (event: MonacoAppearanceChangeEvent) => void; + +const COLOR_PATTERN = /^(?:#[0-9a-f]{3,8}|rgba?\(\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/i; +const TOKEN_COLOR_PATTERN = /^(?:[0-9a-f]{6}|[0-9a-f]{8}|#[0-9a-f]{3,8}|rgba?\(\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/i; +const FONT_STYLE_PATTERN = /^(?:|italic|bold|underline|strikethrough)(?: (?:italic|bold|underline|strikethrough))*$/; +const THEME_ID_PATTERN = /^[a-z][a-z0-9-]{0,95}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export class MonacoAppearanceAdapter implements AppearanceRendererAdapter<'monaco'> { + readonly id = 'monaco'; + + private monaco: MonacoModule | null = null; + private settings: MonacoAppearanceSettings | null = null; + private revision = 0; + private currentThemeId: string | null = null; + private listeners = new Set(); + + validate(settings: unknown): string[] { + const errors: string[] = []; + if (!isRecord(settings)) return ['Settings must be an object']; + const known = new Set(['id', 'base', 'inherit', 'rules', 'colors']); + Object.keys(settings).forEach(key => { + if (!known.has(key)) errors.push(`Unknown setting: ${key}`); + }); + if (typeof settings.id !== 'string' || !THEME_ID_PATTERN.test(settings.id)) { + errors.push('id must contain only lowercase letters, digits, and hyphens'); + } + if (!['vs', 'vs-dark', 'hc-black', 'hc-light'].includes(String(settings.base))) { + errors.push('base must be a supported Monaco base theme'); + } + if (typeof settings.inherit !== 'boolean') errors.push('inherit must be boolean'); + if (!Array.isArray(settings.rules) || settings.rules.length > 512) { + errors.push('rules must be an array with at most 512 entries'); + } else { + settings.rules.forEach((rule, index) => { + if (!isRecord(rule) || typeof rule.token !== 'string' || rule.token.length > 160) { + errors.push(`rules.${index}.token is invalid`); + return; + } + const keys = new Set(['token', 'foreground', 'background', 'fontStyle']); + Object.keys(rule).forEach(key => { + if (!keys.has(key)) errors.push(`rules.${index} has unknown field ${key}`); + }); + for (const key of ['foreground', 'background'] as const) { + const color = rule[key]; + if (color !== undefined && (typeof color !== 'string' || !TOKEN_COLOR_PATTERN.test(color))) { + errors.push(`rules.${index}.${key} is not a supported color`); + } + } + if (rule.fontStyle !== undefined && (typeof rule.fontStyle !== 'string' || !FONT_STYLE_PATTERN.test(rule.fontStyle))) { + errors.push(`rules.${index}.fontStyle is invalid`); + } + }); + } + if (!isRecord(settings.colors) || Object.keys(settings.colors).length > 512) { + errors.push('colors must be an object with at most 512 entries'); + } else { + Object.entries(settings.colors).forEach(([key, value]) => { + if (!/^[A-Za-z][A-Za-z0-9.]{0,159}$/.test(key)) errors.push(`colors.${key} has an invalid key`); + if (typeof value !== 'string' || !COLOR_PATTERN.test(value)) errors.push(`colors.${key} is not a supported color`); + }); + } + return errors; + } + + async apply( + next: Readonly | undefined, + _previous: Readonly | undefined, + context: AppearanceRendererContext, + ): Promise { + const settings = next ?? null; + if (settings) { + const errors = this.validate(settings); + if (errors.length > 0) throw new Error(`Invalid Monaco appearance: ${errors.join('; ')}`); + } + this.settings = settings; + this.revision = context.revision; + if (this.monaco && settings) this.applyToMonaco(this.monaco, settings, context.revision); + } + + initialize(): void { + if (this.monaco && this.settings) this.applyToMonaco(this.monaco, this.settings, this.revision); + } + + attachMonaco(monaco: MonacoModule): string { + this.monaco = monaco; + const settings = this.requireSettings(); + this.applyToMonaco(monaco, settings, this.revision); + return settings.id; + } + + getCurrentThemeId(): string { + return this.currentThemeId ?? this.requireSettings().id; + } + + subscribe(listener: MonacoAppearanceListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private applyToMonaco(monaco: MonacoModule, settings: MonacoAppearanceSettings, revision: number): void { + const previousThemeId = this.currentThemeId; + monaco.editor.defineTheme(settings.id, { + base: settings.base, + inherit: settings.inherit, + rules: settings.rules, + colors: settings.colors, + }); + monaco.editor.setTheme(settings.id); + monaco.editor.getEditors().forEach((editor, index) => { + try { + editor.updateOptions({}); + } catch (error) { + log.warn('Failed to refresh editor after appearance change', { index, error }); + } + }); + this.currentThemeId = settings.id; + if (previousThemeId !== settings.id) { + const event = { previousThemeId, currentThemeId: settings.id, revision }; + this.listeners.forEach(listener => listener(event)); + } + } + + private requireSettings(): MonacoAppearanceSettings { + if (!this.settings) throw new Error('Monaco appearance has not been applied'); + return this.settings; + } +} + +export const monacoAppearanceAdapter = new MonacoAppearanceAdapter(); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/PluginAppearanceProjection.ts b/src/web-ui/src/infrastructure/appearance/adapters/PluginAppearanceProjection.ts new file mode 100644 index 0000000000..69d05dd36d --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/PluginAppearanceProjection.ts @@ -0,0 +1,30 @@ +import type { AppearancePalette, ColorValue } from '../builtins/AppearancePalette'; + +export const PLUGIN_APPEARANCE_COLOR_KEYS = [ + 'primary', + 'secondary', + 'accent', + 'success', + 'warning', + 'error', + 'info', +] as const; + +export type PluginAppearanceColorKey = typeof PLUGIN_APPEARANCE_COLOR_KEYS[number]; +export type PluginAppearanceColorProjection = Record; + +export function createPluginAppearanceColorProjection( + palette: Pick, +): PluginAppearanceColorProjection { + const { colors } = palette; + + return { + primary: colors.accent[500], + secondary: colors.purple?.[500] ?? colors.accent[600], + accent: colors.accent[600], + success: colors.semantic.success, + warning: colors.semantic.warning, + error: colors.semantic.error, + info: colors.semantic.info, + }; +} diff --git a/src/web-ui/src/infrastructure/appearance/adapters/RendererAppearanceContracts.test.ts b/src/web-ui/src/infrastructure/appearance/adapters/RendererAppearanceContracts.test.ts new file mode 100644 index 0000000000..47cc3800f5 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/RendererAppearanceContracts.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { builtinAppearancePackages } from '../builtins/catalog'; +import { cssTokenAppearanceAdapter } from './CssTokenAppearanceAdapter'; +import { MonacoAppearanceAdapter } from './MonacoAppearanceAdapter'; +import { widgetAppearanceAdapter } from './WidgetAppearanceAdapter'; + +describe('renderer appearance contracts', () => { + it('accepts every built-in CSS token payload and rejects unregistered token names', () => { + builtinAppearancePackages.forEach(pkg => { + expect(cssTokenAppearanceAdapter.validate(pkg.renderers?.['css-tokens']?.settings)).toEqual([]); + }); + + const settings = builtinAppearancePackages[0].renderers?.['css-tokens']?.settings; + expect(settings).toBeDefined(); + expect(cssTokenAppearanceAdapter.validate({ + ...settings, + tokens: { ...settings?.tokens, '--bf-appearance-token-unregistered': '#ffffff' }, + })).toContain('Unsupported CSS token name: --bf-appearance-token-unregistered'); + }); + + it('accepts every built-in Widget payload and rejects unregistered variables', () => { + builtinAppearancePackages.forEach(pkg => { + expect(widgetAppearanceAdapter.validate(pkg.renderers?.['generative-widget']?.settings)).toEqual([]); + }); + + const settings = builtinAppearancePackages[0].renderers?.['generative-widget']?.settings; + expect(settings).toBeDefined(); + expect(widgetAppearanceAdapter.validate({ + ...settings, + vars: { ...settings?.vars, '--unregistered-widget-variable': '#ffffff' }, + })).toContain('vars.--unregistered-widget-variable is not registered by the widget host contract'); + }); + + it('enforces Monaco theme names before Monaco is attached', async () => { + const adapter = new MonacoAppearanceAdapter(); + const validSettings = { + id: 'appearance-bitfun-linglong', + base: 'vs-dark' as const, + inherit: true, + rules: [], + colors: { 'editor.background': '#0b1420' }, + }; + + expect(adapter.validate(validSettings)).toEqual([]); + expect(adapter.validate({ ...validSettings, id: 'appearance.bitfun-linglong' })).toContain( + 'id must contain only lowercase letters, digits, and hyphens', + ); + await expect(adapter.apply( + { ...validSettings, id: 'appearance.bitfun-linglong' }, + undefined, + { revision: 1, appearanceId: 'bitfun-linglong', mode: 'dark', globals: {}, assets: {} }, + )).rejects.toThrow('Invalid Monaco appearance'); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/WidgetAppearanceAdapter.ts b/src/web-ui/src/infrastructure/appearance/adapters/WidgetAppearanceAdapter.ts new file mode 100644 index 0000000000..3c238742da --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/WidgetAppearanceAdapter.ts @@ -0,0 +1,57 @@ +import type { AppearanceRendererAdapter, AppearanceRendererContext, WidgetAppearanceSettings } from '../types'; +import { WIDGET_APPEARANCE_VARIABLE_NAMES } from './widgetAppearanceVariables'; + +const SAFE_VALUE_PATTERN = /^(?!.*(?:https?:\/\/|javascript:|data:|url\s*\(|[{};@\\]))[\w\s#.,%()'"+\-/*]+$/i; +const ALLOWED_VARIABLE_NAMES: ReadonlySet = new Set(WIDGET_APPEARANCE_VARIABLE_NAMES); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export class WidgetAppearanceAdapter implements AppearanceRendererAdapter<'generative-widget'> { + readonly id = 'generative-widget'; + private settings: WidgetAppearanceSettings | null = null; + private listeners = new Set<() => void>(); + + validate(settings: unknown): string[] { + const errors: string[] = []; + if (!isRecord(settings)) return ['Settings must be an object']; + Object.keys(settings).forEach(key => { + if (!['id', 'mode', 'vars'].includes(key)) errors.push(`Unknown setting: ${key}`); + }); + if (typeof settings.id !== 'string' || !/^[a-z][a-z0-9.-]{0,95}$/.test(settings.id)) errors.push('id is invalid'); + if (!['light', 'dark'].includes(String(settings.mode))) errors.push('mode must be light or dark'); + if (!isRecord(settings.vars) || Object.keys(settings.vars).length > 256) { + errors.push('vars must be an object with at most 256 entries'); + } else { + Object.entries(settings.vars).forEach(([key, value]) => { + if (!ALLOWED_VARIABLE_NAMES.has(key)) errors.push(`vars.${key} is not registered by the widget host contract`); + if (typeof value !== 'string' || value.length > 256 || !SAFE_VALUE_PATTERN.test(value)) { + errors.push(`vars.${key} has an unsafe value`); + } + }); + } + return errors; + } + + apply(next: Readonly | undefined, _previous: Readonly | undefined, _context: AppearanceRendererContext): void { + if (next) { + const errors = this.validate(next); + if (errors.length > 0) throw new Error(`Invalid widget appearance: ${errors.join('; ')}`); + this.settings = next as WidgetAppearanceSettings; + } else this.settings = null; + this.listeners.forEach(listener => listener()); + } + + getSettings(): WidgetAppearanceSettings { + if (!this.settings) throw new Error('Widget appearance has not been applied'); + return this.settings; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +export const widgetAppearanceAdapter = new WidgetAppearanceAdapter(); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/XtermAppearanceAdapter.ts b/src/web-ui/src/infrastructure/appearance/adapters/XtermAppearanceAdapter.ts new file mode 100644 index 0000000000..84b924a65a --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/XtermAppearanceAdapter.ts @@ -0,0 +1,97 @@ +import type { ITheme } from '@xterm/xterm'; +import type { + AppearanceRendererAdapter, + AppearanceRendererContext, + XtermAppearanceSettings, + XtermAppearanceSurface, +} from '../types'; + +type XtermAppearanceListener = () => void; + +const COLOR_KEYS: ReadonlySet = new Set([ + 'background', 'foreground', 'cursor', 'cursorAccent', 'selectionBackground', + 'selectionForeground', 'selectionInactiveBackground', 'black', 'red', 'green', + 'yellow', 'blue', 'magenta', 'cyan', 'white', 'brightBlack', 'brightRed', + 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', + 'brightWhite', +]); +const COLOR_PATTERN = /^(?:transparent|#[0-9a-f]{3,8}|rgba?\(\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/i; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateColors(value: unknown, path: string): string[] { + if (!isRecord(value)) return [`${path} must be an object`]; + const errors: string[] = []; + Object.entries(value).forEach(([key, color]) => { + if (!COLOR_KEYS.has(key as keyof ITheme)) errors.push(`${path}.${key} is not supported`); + if (typeof color !== 'string' || !COLOR_PATTERN.test(color)) errors.push(`${path}.${key} is not a supported color`); + }); + for (const key of ['background', 'foreground', 'cursor'] as const) { + if (typeof value[key] !== 'string') errors.push(`${path}.${key} is required`); + } + return errors; +} + +export class XtermAppearanceAdapter implements AppearanceRendererAdapter<'xterm'> { + readonly id = 'xterm'; + private settings: XtermAppearanceSettings | null = null; + private listeners = new Set(); + + validate(settings: unknown): string[] { + const errors: string[] = []; + if (!isRecord(settings)) return ['Settings must be an object']; + Object.keys(settings).forEach(key => { + if (!['surfaces', 'fontWeight', 'fontWeightBold'].includes(key)) errors.push(`Unknown setting: ${key}`); + }); + if (!isRecord(settings.surfaces)) { + errors.push('surfaces must be an object'); + } else { + Object.keys(settings.surfaces).forEach(key => { + if (!['terminal', 'output'].includes(key)) errors.push(`Unknown xterm surface: ${key}`); + }); + errors.push(...validateColors(settings.surfaces.terminal, 'surfaces.terminal')); + errors.push(...validateColors(settings.surfaces.output, 'surfaces.output')); + } + if (!['normal', '500'].includes(String(settings.fontWeight))) errors.push('fontWeight is invalid'); + if (!['bold', '700'].includes(String(settings.fontWeightBold))) errors.push('fontWeightBold is invalid'); + return errors; + } + + apply( + next: Readonly | undefined, + _previous: Readonly | undefined, + _context: AppearanceRendererContext, + ): void { + if (next) { + const errors = this.validate(next); + if (errors.length > 0) throw new Error(`Invalid xterm appearance: ${errors.join('; ')}`); + this.settings = next as XtermAppearanceSettings; + } else this.settings = null; + this.listeners.forEach(listener => listener()); + } + + getColors(surface: XtermAppearanceSurface): ITheme { + return { ...this.requireSettings().surfaces[surface] }; + } + + getFontWeights(): Pick { + return { + fontWeight: this.requireSettings().fontWeight, + fontWeightBold: this.requireSettings().fontWeightBold, + }; + } + + subscribe(listener: XtermAppearanceListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private requireSettings(): XtermAppearanceSettings { + if (!this.settings) throw new Error('xterm appearance has not been applied'); + return this.settings; + } +} + +export const xtermAppearanceAdapter = new XtermAppearanceAdapter(); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/widgetAppearanceVariables.ts b/src/web-ui/src/infrastructure/appearance/adapters/widgetAppearanceVariables.ts new file mode 100644 index 0000000000..0c14e527a7 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/widgetAppearanceVariables.ts @@ -0,0 +1,60 @@ +export const WIDGET_APPEARANCE_VARIABLE_NAMES = [ + '--bf-appearance-token-color-bg-primary', + '--bf-appearance-token-color-static-white', + '--bf-appearance-token-color-static-black', + '--bf-appearance-token-color-bg-secondary', + '--bf-appearance-token-color-bg-tertiary', + '--bf-appearance-token-color-bg-elevated', + '--bf-appearance-token-color-bg-scene', + '--bf-appearance-token-color-text-primary', + '--bf-appearance-token-color-text-secondary', + '--bf-appearance-token-color-text-muted', + '--bf-appearance-token-color-text-disabled', + '--bf-appearance-token-color-accent-500', + '--bf-appearance-token-color-accent-500-rgb', + '--bf-appearance-token-color-accent-600', + '--bf-appearance-token-color-success', + '--bf-appearance-token-color-success-bg', + '--bf-appearance-token-color-warning', + '--bf-appearance-token-color-warning-bg', + '--bf-appearance-token-color-error', + '--bf-appearance-token-color-error-bg', + '--bf-appearance-token-color-info', + '--bf-appearance-token-color-info-bg', + '--bf-appearance-token-color-info-border', + '--bf-appearance-token-border-subtle', + '--bf-appearance-token-border-base', + '--bf-appearance-token-border-medium', + '--bf-appearance-token-element-bg-subtle', + '--bf-appearance-token-element-bg-soft', + '--bf-appearance-token-element-bg-base', + '--bf-appearance-token-element-bg-medium', + '--bf-appearance-token-element-bg-hover', + '--bf-appearance-token-shadow-xs', + '--bf-appearance-token-shadow-sm', + '--bf-appearance-token-motion-fast', + '--bf-appearance-token-motion-base', + '--bf-appearance-token-easing-standard', + '--bf-appearance-token-font-family-sans', + '--bf-appearance-token-font-family-mono', + '--bf-appearance-token-btn-primary-bg', + '--bf-appearance-token-btn-primary-color', + '--bf-appearance-token-btn-primary-border', + '--bf-appearance-token-btn-primary-shadow', + '--bf-appearance-token-btn-primary-hover-bg', + '--bf-appearance-token-btn-primary-hover-color', + '--bf-appearance-token-btn-primary-hover-border', + '--bf-appearance-token-btn-primary-hover-shadow', + '--bf-appearance-token-btn-primary-hover-transform', + '--bf-appearance-token-btn-primary-active-bg', + '--bf-appearance-token-btn-primary-active-color', + '--bf-appearance-token-btn-primary-active-border', + '--bf-appearance-token-btn-primary-active-shadow', + '--bf-appearance-token-btn-primary-active-transform', + '--bf-appearance-token-btn-ghost-color', + '--bf-appearance-token-btn-ghost-hover-bg', + '--bf-appearance-token-btn-ghost-hover-color', + '--bf-appearance-token-btn-ghost-hover-border', +] as const; + +export type WidgetAppearanceVariableName = typeof WIDGET_APPEARANCE_VARIABLE_NAMES[number]; diff --git a/src/web-ui/src/infrastructure/appearance/appearanceDomainTokens.ts b/src/web-ui/src/infrastructure/appearance/appearanceDomainTokens.ts new file mode 100644 index 0000000000..fd62f7519d --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/appearanceDomainTokens.ts @@ -0,0 +1,155 @@ +const appearanceToken = (name: string): string => 'var(' + '--bf-appearance-token-' + name + ')'; + +export const APPEARANCE_DOMAIN_TOKENS = { + contextCompression: appearanceToken('domain-context-compression'), + generativeUi: appearanceToken('domain-generative-ui'), + miniApp: appearanceToken('domain-mini-app'), + mermaidDiagram: appearanceToken('domain-mermaid-diagram'), + gitGraphLane: Array.from({ length: 8 }, (_, index) => appearanceToken(`domain-git-lane-${index}`)), + toolIdentity: { + search: appearanceToken('domain-tool-search'), + webSearch: appearanceToken('domain-tool-web-search'), + git: appearanceToken('domain-tool-git'), + terminal: appearanceToken('domain-tool-terminal'), + mcp: appearanceToken('domain-tool-mcp'), + assistantAction: appearanceToken('domain-tool-assistant-action'), + reviewSummary: appearanceToken('domain-tool-review-summary'), + }, + agentCapability: { + docs: appearanceToken('domain-capability-docs'), + testing: appearanceToken('domain-capability-testing'), + creative: appearanceToken('domain-capability-creative'), + ops: appearanceToken('domain-capability-ops'), + }, + insights: { + positive: appearanceToken('domain-insights-positive'), + time: appearanceToken('domain-insights-time'), + neutral: appearanceToken('domain-insights-neutral'), + issue: appearanceToken('domain-insights-issue'), + }, + progress: { + compacting: appearanceToken('domain-progress-compacting'), + }, + templateContext: { + memories: appearanceToken('domain-template-memories'), + }, + reviewTeam: { + memberDefault: appearanceToken('domain-review-member-default'), + worker: appearanceToken('domain-review-worker'), + judge: appearanceToken('domain-review-judge'), + }, + tealAction: appearanceToken('domain-teal-action'), + todo: appearanceToken('domain-todo'), + textStroke: Array.from({ length: 5 }, (_, index) => appearanceToken(`domain-text-stroke-${index}`)), + inspectorOverlay: { + activeBorder: appearanceToken('domain-inspector-active-border'), + activeBackground: appearanceToken('domain-inspector-active-background'), + activeBorderSubtle: appearanceToken('domain-inspector-active-border-subtle'), + selectedBorder: appearanceToken('domain-inspector-selected-border'), + selectedBackground: appearanceToken('domain-inspector-selected-background'), + browserTooltipBackground: appearanceToken('domain-inspector-browser-tooltip-background'), + mainTooltipBackground: appearanceToken('domain-inspector-main-tooltip-background'), + tooltipText: appearanceToken('domain-inspector-tooltip-text'), + tooltipShadow: appearanceToken('domain-inspector-tooltip-shadow'), + staticWhite: appearanceToken('color-static-white'), + }, +} as const; + +const languageToken = (name: string): string => appearanceToken(`language-${name}`); + +export const APPEARANCE_LANGUAGE_TOKENS = { + typescript: languageToken('blue'), + typescriptReact: languageToken('cyan'), + javascript: languageToken('yellow'), + javascriptReact: languageToken('cyan'), + python: languageToken('blue'), + rust: languageToken('orange'), + go: languageToken('cyan'), + java: languageToken('orange'), + kotlin: languageToken('purple'), + cpp: languageToken('red'), + c: languageToken('slate'), + csharp: languageToken('green'), + swift: languageToken('red'), + php: languageToken('purple'), + ruby: languageToken('red'), + scala: languageToken('red'), + dart: languageToken('cyan'), + lua: languageToken('blue'), + r: languageToken('blue'), + html: languageToken('red'), + xml: languageToken('blue'), + vue: languageToken('green'), + svelte: languageToken('orange'), + css: languageToken('blue'), + scss: languageToken('purple'), + sass: languageToken('purple'), + less: languageToken('blue'), + json: languageToken('yellow'), + yaml: languageToken('red'), + toml: languageToken('orange'), + sql: languageToken('orange'), + graphql: languageToken('purple'), + dockerfile: languageToken('blue'), + makefile: languageToken('green'), + ini: languageToken('slate'), + env: languageToken('yellow'), + shell: languageToken('green'), + powershell: languageToken('blue'), + batch: languageToken('green'), + markdown: languageToken('blue'), + restructuredtext: languageToken('slate'), + image: languageToken('purple'), + audio: languageToken('green'), + video: languageToken('red'), + font: languageToken('orange'), + archive: languageToken('purple'), + binary: languageToken('slate'), + plaintext: languageToken('slate'), +} as const; + +const CODE_SNIPPET_LANGUAGE_TOKENS = { + javascript: APPEARANCE_LANGUAGE_TOKENS.javascript, + typescript: APPEARANCE_LANGUAGE_TOKENS.typescript, + python: APPEARANCE_LANGUAGE_TOKENS.python, + rust: APPEARANCE_LANGUAGE_TOKENS.rust, + go: APPEARANCE_LANGUAGE_TOKENS.go, + java: APPEARANCE_LANGUAGE_TOKENS.java, + html: APPEARANCE_LANGUAGE_TOKENS.html, + css: APPEARANCE_LANGUAGE_TOKENS.css, + scss: APPEARANCE_LANGUAGE_TOKENS.scss, + fallback: APPEARANCE_LANGUAGE_TOKENS.plaintext, +} as const; + +export function getCodeSnippetLanguageAccent(language?: string): string { + if (!language) return CODE_SNIPPET_LANGUAGE_TOKENS.fallback; + return CODE_SNIPPET_LANGUAGE_TOKENS[language as keyof typeof CODE_SNIPPET_LANGUAGE_TOKENS] + ?? CODE_SNIPPET_LANGUAGE_TOKENS.fallback; +} + +const prismToken = (mode: 'light' | 'dark', role: string): string => appearanceToken(`prism-${mode}-${role}`); + +export const APPEARANCE_PRISM_TOKENS = { + light: { + foreground: prismToken('light', 'foreground'), + comment: prismToken('light', 'comment'), + keyword: prismToken('light', 'keyword'), + string: prismToken('light', 'string'), + functionName: prismToken('light', 'function'), + number: prismToken('light', 'number'), + tag: prismToken('light', 'tag'), + punctuation: prismToken('light', 'punctuation'), + property: prismToken('light', 'property'), + }, + dark: { + foreground: prismToken('dark', 'foreground'), + comment: prismToken('dark', 'comment'), + keyword: prismToken('dark', 'keyword'), + string: prismToken('dark', 'string'), + functionName: prismToken('dark', 'function'), + number: prismToken('dark', 'number'), + tag: prismToken('dark', 'tag'), + punctuation: prismToken('dark', 'punctuation'), + property: prismToken('dark', 'property'), + }, +} as const; diff --git a/src/web-ui/src/infrastructure/appearance/appearancePropertyProfiles.ts b/src/web-ui/src/infrastructure/appearance/appearancePropertyProfiles.ts new file mode 100644 index 0000000000..31e7c7a30a --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/appearancePropertyProfiles.ts @@ -0,0 +1,65 @@ +import type { + AppearancePropertyProfile, + AppearanceStyleProperty, +} from './types'; + +const PAINT_PROPERTIES = [ + 'backgroundColor', 'backgroundImage', 'backgroundSize', 'backgroundPosition', + 'backgroundRepeat', 'backgroundImages', 'backgroundSizes', 'backgroundPositions', + 'backgroundRepeats', 'backgroundBlendModes', 'color', 'caretColor', 'accentColor', + 'textDecorationColor', 'borderColor', 'borderTopColor', 'borderRightColor', + 'borderBottomColor', 'borderLeftColor', 'borderWidth', 'borderTopWidth', + 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle', + 'borderRadius', 'borderTopLeftRadius', 'borderTopRightRadius', + 'borderBottomRightRadius', 'borderBottomLeftRadius', 'outlineColor', 'outlineWidth', + 'outlineOffset', 'outlineStyle', 'boxShadow', 'opacity', 'fontFamily', 'fontSize', + 'fontWeight', 'fontStyle', 'fontVariantNumeric', 'lineHeight', 'letterSpacing', + 'textAlign', 'verticalAlign', 'textIndent', 'textDecoration', 'textTransform', + 'textOverflow', 'whiteSpace', 'wordBreak', 'overflowWrap', 'cursor', 'transition', + 'transform', 'filter', 'backdropBlur', 'objectFit', 'objectPosition', + 'mixBlendMode', 'isolation', +] as const satisfies readonly AppearanceStyleProperty[]; + +const CONTROL_PROPERTIES = [ + ...PAINT_PROPERTIES, + 'display', 'flexDirection', 'flexWrap', 'alignItems', 'alignContent', + 'justifyContent', + 'boxSizing', 'width', 'minWidth', 'maxWidth', 'height', 'minHeight', 'maxHeight', + 'padding', 'paddingBlock', 'paddingInline', 'paddingTop', 'paddingRight', + 'paddingBottom', 'paddingLeft', 'gap', 'rowGap', 'columnGap', 'flexGrow', + 'flexShrink', 'flexBasis', 'alignSelf', 'aspectRatio', +] as const satisfies readonly AppearanceStyleProperty[]; + +const CONTAINER_PROPERTIES = [ + ...CONTROL_PROPERTIES, + 'justifySelf', 'justifyItems', 'placeItems', 'placeContent', + 'gridTemplateColumns', 'gridTemplateRows', 'gridAutoFlow', 'gridColumnSpan', + 'gridRowSpan', 'margin', 'marginBlock', 'marginInline', 'marginTop', 'marginRight', + 'marginBottom', 'marginLeft', 'overflow', 'overflowX', 'overflowY', +] as const satisfies readonly AppearanceStyleProperty[]; + +const LAYOUT_PROPERTIES = [ + ...CONTAINER_PROPERTIES, + 'order', 'position', 'insetBlock', 'insetInline', 'top', 'right', 'bottom', + 'left', 'zIndex', +] as const satisfies readonly AppearanceStyleProperty[]; + +const PROFILE_PROPERTIES: Record = { + paint: PAINT_PROPERTIES, + control: CONTROL_PROPERTIES, + container: CONTAINER_PROPERTIES, + layout: LAYOUT_PROPERTIES, + overlay: LAYOUT_PROPERTIES, +}; + +export const DEFAULT_APPEARANCE_PROPERTY_PROFILE: AppearancePropertyProfile = 'container'; + +export function getAppearanceProfileProperties( + profile: AppearancePropertyProfile = DEFAULT_APPEARANCE_PROPERTY_PROFILE, +): readonly AppearanceStyleProperty[] { + return PROFILE_PROPERTIES[profile]; +} + +export function getDefaultForceableAppearanceProperties(): readonly AppearanceStyleProperty[] { + return PAINT_PROPERTIES; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/AppearancePalette.ts b/src/web-ui/src/infrastructure/appearance/builtins/AppearancePalette.ts new file mode 100644 index 0000000000..9455b324e3 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/AppearancePalette.ts @@ -0,0 +1,235 @@ +export type AppearancePaletteMode = 'dark' | 'light'; +export type AppearancePaletteId = string; +export type ColorValue = string; + +export interface BackgroundColors { + primary: ColorValue; + secondary: ColorValue; + tertiary: ColorValue; + elevated: ColorValue; + workbench: ColorValue; + scene: ColorValue; +} + +export interface TextColors { + primary: ColorValue; + secondary: ColorValue; + muted: ColorValue; + disabled: ColorValue; +} + +export interface AccentColors { + 50: ColorValue; + 100: ColorValue; + 200: ColorValue; + 300: ColorValue; + 400: ColorValue; + 500: ColorValue; + 600: ColorValue; + 700: ColorValue; +} + +export type SecondaryAccentStop = 100 | 200 | 500 | 600; +export type SecondaryAccentColors = Pick; + +export interface SemanticColors { + success: ColorValue; + successBg: ColorValue; + successBorder: ColorValue; + warning: ColorValue; + warningBg: ColorValue; + warningBorder: ColorValue; + error: ColorValue; + errorBg: ColorValue; + errorBorder: ColorValue; + info: ColorValue; + infoBg: ColorValue; + infoBorder: ColorValue; +} + +export interface BorderColors { + subtle: ColorValue; + base: ColorValue; + medium: ColorValue; + strong: ColorValue; + prominent: ColorValue; +} + +export interface ElementBackgrounds { + subtle: ColorValue; + soft: ColorValue; + base: ColorValue; + medium: ColorValue; + strong: ColorValue; +} + +export interface GitColors { + branch: ColorValue; + branchBg: ColorValue; + changes: ColorValue; + added: ColorValue; + deleted: ColorValue; + staged: ColorValue; +} + +export interface ScrollbarColors { + thumb: ColorValue; + thumbHover: ColorValue; +} + +export interface ShadowConfig { + xs: string; + sm: string; + base: string; + lg: string; + xl: string; +} + +export interface BlurConfig { + subtle: string; + base: string; +} + +export interface RadiusConfig { + sm: string; + base: string; + lg: string; + xl: string; + '2xl': string; + full: string; +} + +export interface SpacingConfig { + 1: string; + 2: string; + 3: string; + 4: string; + 5: string; + 6: string; + 8: string; + 10: string; + 12: string; + 16: string; +} + +export interface OpacityConfig { + disabled: number; + hover: number; + focus: number; +} + +export interface ButtonConfig { + primary: { + default: { background: ColorValue; color: ColorValue; border: ColorValue; shadow?: string }; + hover: { background: ColorValue; color: ColorValue; border: ColorValue; shadow?: string; transform?: string }; + active: { background: ColorValue; color: ColorValue; border: ColorValue; shadow?: string; transform?: string }; + }; + ghost: { + default: { color: ColorValue }; + hover: { background: ColorValue; color: ColorValue; border: ColorValue }; + }; +} + +export interface MotionConfig { + instant: string; + fast: string; + base: string; + slow: string; +} + +export interface EasingConfig { + standard: string; + decelerate: string; + smooth: string; +} + +export interface FontConfig { + sans: string; + mono: string; +} + +export interface FontWeightConfig { + normal: number; + medium: number; + semibold: number; +} + +export interface FontSizeConfig { + xs: string; + sm: string; + base: string; + lg: string; + xl: string; + '2xl': string; + '3xl': string; + '4xl': string; +} + +export interface LineHeightConfig { + tight: number; + base: number; + relaxed: number; +} + +export interface MonacoEditorColors { + background: ColorValue; + foreground: ColorValue; + lineHighlight: ColorValue; + selection: ColorValue; + cursor: ColorValue; + [key: string]: ColorValue; +} + +export interface MonacoTokenRule { + token: string; + foreground?: string; + background?: string; + fontStyle?: string; +} + +export interface MonacoAppearancePaletteConfig { + base: 'vs' | 'vs-dark' | 'hc-black' | 'hc-light'; + inherit: boolean; + rules: MonacoTokenRule[]; + colors: MonacoEditorColors; +} + +export interface AppearancePalette { + id: AppearancePaletteId; + name: string; + type: AppearancePaletteMode; + description?: string; + author?: string; + version?: string; + colors: { + background: BackgroundColors; + text: TextColors; + accent: AccentColors; + purple?: SecondaryAccentColors; + semantic: SemanticColors; + border: BorderColors; + element: ElementBackgrounds; + git: GitColors; + scrollbar?: ScrollbarColors; + }; + effects: { + shadow: ShadowConfig; + blur: BlurConfig; + radius: RadiusConfig; + spacing: SpacingConfig; + opacity: OpacityConfig; + }; + motion: { + duration: MotionConfig; + easing: EasingConfig; + }; + typography: { + font: FontConfig; + weight: FontWeightConfig; + size: FontSizeConfig; + lineHeight: LineHeightConfig; + }; + components?: { button?: ButtonConfig }; + monaco?: MonacoAppearancePaletteConfig; + layout?: { sceneViewportBorder?: boolean }; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts b/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts new file mode 100644 index 0000000000..e30a6be56d --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts @@ -0,0 +1,194 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; + +import { builtinAppearancePalettes } from './palettes'; +import { + PLUGIN_APPEARANCE_COLOR_KEYS, + createPluginAppearanceColorProjection, +} from '../adapters/PluginAppearanceProjection'; +import { + createAccentScale, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + overlayBlack, + overlayWhite, + rgbFromHex, + rgbaFromHex, +} from './paletteHelpers'; + +function hashAppearance(appearance: unknown): string { + return createHash('sha256') + .update(JSON.stringify(appearance)) + .digest('hex'); +} + +describe('builtin appearance preset output', () => { + it('formats hex palette references as stable rgb strings', () => { + expect(rgbFromHex('#00e6ff')).toBe('rgb(0, 230, 255)'); + expect(rgbaFromHex('#00e6ff', 0.12)).toBe('rgba(0, 230, 255, 0.12)'); + expect(rgbaFromHex('#00e6ff', '0.12')).toBe('rgba(0, 230, 255, 0.12)'); + expect(overlayBlack(0.3)).toBe('rgba(0, 0, 0, 0.3)'); + expect(overlayWhite(0.08)).toBe('rgba(255, 255, 255, 0.08)'); + }); + + it('aliases staged git colors to added colors unless an appearance overrides them', () => { + expect(createGitColors({ + branch: '#64748b', + branchBg: 'rgba(100, 116, 139, 0.1)', + changes: '#f59e0b', + added: '#22c55e', + deleted: '#ef4444', + })).toMatchObject({ + staged: '#22c55e', + }); + + expect(createGitColors({ + branch: '#64748b', + branchBg: 'rgba(100, 116, 139, 0.1)', + changes: '#f59e0b', + added: '#22c55e', + deleted: '#ef4444', + staged: '#10b981', + })).toMatchObject({ + staged: '#10b981', + }); + }); + + it('derives repeated palette families from compact authoring inputs', () => { + expect(createAccentScale({ + base: '#60a5fa', + hover: '#3b82f6', + })).toEqual({ + 50: 'rgba(96, 165, 250, 0.04)', + 100: 'rgba(96, 165, 250, 0.08)', + 200: 'rgba(96, 165, 250, 0.15)', + 300: 'rgba(96, 165, 250, 0.25)', + 400: 'rgba(96, 165, 250, 0.4)', + 500: '#60a5fa', + 600: '#3b82f6', + 700: 'rgba(59, 130, 246, 0.8)', + }); + + expect(createSecondaryAccentScale({ + base: '#8b5cf6', + hover: '#7c3aed', + })).toEqual({ + 100: 'rgba(139, 92, 246, 0.08)', + 200: 'rgba(139, 92, 246, 0.15)', + 500: '#8b5cf6', + 600: '#7c3aed', + }); + + expect(createSemanticColors({ + success: '#34d399', + warning: '#f59e0b', + error: '#ef4444', + info: '#a1a1aa', + })).toMatchObject({ + successBg: 'rgba(52, 211, 153, 0.1)', + successBorder: 'rgba(52, 211, 153, 0.3)', + warningBg: 'rgba(245, 158, 11, 0.1)', + errorBorder: 'rgba(239, 68, 68, 0.3)', + infoBg: 'rgba(161, 161, 170, 0.1)', + infoBorder: 'rgba(161, 161, 170, 0.3)', + }); + }); + + it('does not carry retired runtime-only authoring stops in builtin appearance schemas', () => { + for (const appearance of builtinAppearancePalettes) { + expect(appearance.colors.accent).not.toHaveProperty('800'); + expect(appearance.colors.purple).not.toHaveProperty('50'); + expect(appearance.colors.purple).not.toHaveProperty('400'); + expect(appearance.colors.purple).not.toHaveProperty('800'); + expect(appearance.colors.background).not.toHaveProperty('quaternary'); + expect(appearance.colors.background).not.toHaveProperty('tooltip'); + expect(appearance.colors.element).not.toHaveProperty('elevated'); + expect(appearance.typography.weight).not.toHaveProperty('bold'); + } + }); + + it('keeps near-neutral preset foregrounds on canonical stops', () => { + const serializedAppearances = JSON.stringify(builtinAppearancePalettes).toLowerCase(); + + expect(serializedAppearances).not.toContain('#fafafa'); + expect(serializedAppearances).not.toContain('#e2e6eb'); + expect(serializedAppearances).not.toContain('#f0f2f5'); + }); + + it('projects builtin appearances to a compact OpenCode-compatible plugin color key set', () => { + expect(PLUGIN_APPEARANCE_COLOR_KEYS).toEqual([ + 'primary', + 'secondary', + 'accent', + 'success', + 'warning', + 'error', + 'info', + ]); + + for (const appearance of builtinAppearancePalettes) { + const projection = createPluginAppearanceColorProjection(appearance); + + expect(Object.keys(projection).sort()).toEqual([...PLUGIN_APPEARANCE_COLOR_KEYS].sort()); + expect(projection.primary).toBe(appearance.colors.accent[500]); + expect(projection.secondary).toBe(appearance.colors.purple?.[500] ?? appearance.colors.accent[600]); + expect(projection.accent).toBe(appearance.colors.accent[600]); + expect(projection.success).toBe(appearance.colors.semantic.success); + expect(projection.warning).toBe(appearance.colors.semantic.warning); + expect(projection.error).toBe(appearance.colors.semantic.error); + expect(projection.info).toBe(appearance.colors.semantic.info); + } + }); + + it('keeps resolved preset objects stable across helper refactors', () => { + expect(builtinAppearancePalettes.map(appearance => ({ + id: appearance.id, + type: appearance.type, + hash: hashAppearance(appearance), + }))).toMatchInlineSnapshot(` + [ + { + "hash": "b70ab1232690e55f3446d84c5c5d4459c508c6cd5f1e0bfe8565eba82e97ea6e", + "id": "bitfun-light", + "type": "light", + }, + { + "hash": "6c426dc8aacd8095fdf5a69ed80b4d42c0235cf72f61b20186fc14d5ebcb320b", + "id": "bitfun-slate", + "type": "dark", + }, + { + "hash": "db937836a31fb02d67f6035f1b3a036b8161f3833fdbe2634f37936ba7ef6357", + "id": "bitfun-dark", + "type": "dark", + }, + { + "hash": "e1455a17d5891da49f584ac1da6740a04db152afd6cdd571a9406f86811e6c8b", + "id": "bitfun-midnight", + "type": "dark", + }, + { + "hash": "7944cb574fd2b30cd4ed766e1b25e86eabd4c90b4a0e054489231a68de5cc424", + "id": "bitfun-china-style", + "type": "light", + }, + { + "hash": "f66e855842633612ca3aed2c30ff8df057dd0768eeb47e89e049efab8968f227", + "id": "bitfun-china-night", + "type": "dark", + }, + { + "hash": "b72278893e14c1b0cef86449facf7ba61b4f714f077c3923241ebb012a90e3c8", + "id": "bitfun-cyber", + "type": "dark", + }, + { + "hash": "abcd6c91fdc1c03ea66d7d460401351620d672130e4742cce6a8be7f04b22626", + "id": "bitfun-tokyo-night", + "type": "dark", + }, + ] + `); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts b/src/web-ui/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts new file mode 100644 index 0000000000..88d671d055 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts @@ -0,0 +1,62 @@ +import type { AppearancePalette, AppearancePaletteId } from './AppearancePalette'; +import { DEFAULT_DARK_APPEARANCE_ID, DEFAULT_LIGHT_APPEARANCE_ID } from './palettes'; + +export const APPEARANCE_PROMPT_SNAPSHOT_SCHEMA_VERSION = 1 as const; + +export interface AppearancePromptSnapshotEntry { + id: AppearancePaletteId; + mode: AppearancePalette['type']; + bgPrimary: string; + bgSecondary: string; + bgScene: string; + textPrimary: string; + textMuted: string; + accent500: string; + accent600: string; + borderBase: string; + elementBase: string; + radiusBase: string; + spacing4: string; + shadowBase: string; + styleNotes: string; +} + +export interface AppearancePromptSnapshotManifest { + schemaVersion: typeof APPEARANCE_PROMPT_SNAPSHOT_SCHEMA_VERSION; + defaultLightAppearanceId: AppearancePaletteId; + defaultDarkAppearanceId: AppearancePaletteId; + appearances: AppearancePromptSnapshotEntry[]; +} + +export function createAppearancePromptSnapshotEntry( + palette: AppearancePalette, +): AppearancePromptSnapshotEntry { + return { + id: palette.id, + mode: palette.type, + bgPrimary: palette.colors.background.primary, + bgSecondary: palette.colors.background.secondary, + bgScene: palette.colors.background.scene, + textPrimary: palette.colors.text.primary, + textMuted: palette.colors.text.muted, + accent500: palette.colors.accent[500], + accent600: palette.colors.accent[600], + borderBase: palette.colors.border.base, + elementBase: palette.colors.element.base, + radiusBase: palette.effects.radius.base, + spacing4: palette.effects.spacing[4], + shadowBase: palette.effects.shadow.base, + styleNotes: palette.description ?? palette.name, + }; +} + +export function createAppearancePromptSnapshotManifest( + palettes: readonly AppearancePalette[], +): AppearancePromptSnapshotManifest { + return { + schemaVersion: APPEARANCE_PROMPT_SNAPSHOT_SCHEMA_VERSION, + defaultLightAppearanceId: DEFAULT_LIGHT_APPEARANCE_ID, + defaultDarkAppearanceId: DEFAULT_DARK_APPEARANCE_ID, + appearances: palettes.map(createAppearancePromptSnapshotEntry), + }; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts new file mode 100644 index 0000000000..595af79dcc --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts @@ -0,0 +1,800 @@ +import type { AppearancePalette } from './AppearancePalette'; +import type { + AppearanceColorValue, + AppearanceDurationValue, + AppearanceFontFamilyValue, + AppearanceLengthValue, + AppearanceNumberValue, + AppearancePackage, + AppearanceReference, + AppearanceStyle, +} from '../types'; +import { + WIDGET_APPEARANCE_VARIABLE_NAMES, + type WidgetAppearanceVariableName, +} from '../adapters/widgetAppearanceVariables'; + +const ref = (path: string): AppearanceReference => ({ kind: 'ref', path }); +const colorRef = (id: string): AppearanceReference => ref(`globals.colors.${id}`); +const lengthRef = (id: string): AppearanceReference => ref(`globals.lengths.${id}`); +const numberRef = (id: string): AppearanceReference => ref(`globals.numbers.${id}`); +const durationRef = (id: string): AppearanceReference => ref(`globals.durations.${id}`); +const easingRef = (id: string): AppearanceReference => ref(`globals.easings.${id}`); +const fontRef = (id: string): AppearanceReference => ref(`globals.fontFamilies.${id}`); + +function color(value: string): AppearanceColorValue { + const trimmed = value.trim(); + if (trimmed === 'transparent') return { kind: 'transparent' }; + if (/^#[0-9a-f]{3,8}$/i.test(trimmed)) return { kind: 'hex', value: trimmed }; + const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(trimmed); + if (rgb) { + return { + kind: 'rgb', + r: Number(rgb[1]), + g: Number(rgb[2]), + b: Number(rgb[3]), + ...(rgb[4] === undefined ? {} : { a: Number(rgb[4]) }), + }; + } + const hsl = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(trimmed); + if (hsl) { + return { + kind: 'hsl', + h: Number(hsl[1]), + s: Number(hsl[2]), + l: Number(hsl[3]), + ...(hsl[4] === undefined ? {} : { a: Number(hsl[4]) }), + }; + } + throw new Error(`Appearance palette color is invalid: ${value}`); +} + +function length(value: string): AppearanceLengthValue { + const trimmed = value.trim(); + if (trimmed === '0') return { kind: 'zero' }; + const match = /^(-?[\d.]+)(px|rem|%)$/.exec(trimmed); + if (!match) throw new Error(`Appearance palette length is invalid: ${value}`); + const unit = match[2] === '%' ? 'percent' : match[2]; + return { kind: unit, value: Number(match[1]) } as AppearanceLengthValue; +} + +function duration(value: string): AppearanceDurationValue { + const trimmed = value.trim(); + const match = /^([\d.]+)(ms|s)$/.exec(trimmed); + if (!match) throw new Error(`Appearance palette duration is invalid: ${value}`); + return { kind: 'ms', value: Number(match[1]) * (match[2] === 's' ? 1000 : 1) }; +} + +function number(value: number): AppearanceNumberValue { + return { kind: 'number', value }; +} + +function fontFamily(value: string): AppearanceFontFamilyValue { + const families = value.split(',').map(item => item.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean); + return { kind: 'fontFamily', families: families.slice(0, 8) }; +} + +function transition(property: 'all' | 'background-color' | 'border-color' | 'box-shadow' | 'color' | 'opacity' | 'transform' = 'all'): NonNullable { + return [{ property, duration: durationRef('base'), easing: easingRef('standard') }]; +} + +function colorToRgbChannels(value: string): string | null { + const trimmed = value.trim(); + const hex = /^#([0-9a-f]{6})$/i.exec(trimmed); + if (hex) { + const numeric = Number.parseInt(hex[1], 16); + return `${(numeric >> 16) & 255} ${(numeric >> 8) & 255} ${numeric & 255}`; + } + const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(trimmed); + return rgb ? `${rgb[1]} ${rgb[2]} ${rgb[3]}` : null; +} + +function createCssTokens(palette: AppearancePalette): Record { + const { colors, effects, motion, typography } = palette; + const purple = colors.purple ?? { + 100: colors.element.subtle, + 200: colors.element.soft, + 500: colors.accent[500], + 600: colors.accent[600], + }; + const tokens: Record = { + '--bf-appearance-token-color-bg-primary': colors.background.primary, + '--bf-appearance-token-color-bg-secondary': colors.background.secondary, + '--bf-appearance-token-color-bg-tertiary': colors.background.tertiary, + '--bf-appearance-token-color-bg-elevated': colors.background.elevated, + '--bf-appearance-token-color-bg-workbench': colors.background.workbench, + '--bf-appearance-token-color-bg-scene': colors.background.scene, + '--bf-appearance-token-color-static-white': '#ffffff', + '--bf-appearance-token-color-static-black': '#000000', + '--bf-appearance-token-color-static-white-rgb': '255, 255, 255', + '--bf-appearance-token-color-static-black-rgb': '0, 0, 0', + '--bf-appearance-token-color-accent-50': colors.accent[50], + '--bf-appearance-token-color-accent-100': colors.accent[100], + '--bf-appearance-token-color-accent-200': colors.accent[200], + '--bf-appearance-token-color-accent-300': colors.accent[300], + '--bf-appearance-token-color-accent-400': colors.accent[400], + '--bf-appearance-token-color-accent-500': colors.accent[500], + '--bf-appearance-token-color-accent-600': colors.accent[600], + '--bf-appearance-token-color-accent-700': colors.accent[700], + '--bf-appearance-token-color-accent-500-rgb': colorToRgbChannels(colors.accent[500]) + ?? colorToRgbChannels(colors.accent[600]) + ?? '96 165 250', + '--bf-appearance-token-color-purple-100': purple[100], + '--bf-appearance-token-color-purple-200': purple[200], + '--bf-appearance-token-color-purple-500': purple[500], + '--bf-appearance-token-color-purple-600': purple[600], + '--bf-appearance-token-color-overlay-slate-rgb': '15, 23, 42', + '--bf-appearance-token-color-text-primary': colors.text.primary, + '--bf-appearance-token-color-text-secondary': colors.text.secondary, + '--bf-appearance-token-color-text-muted': colors.text.muted, + '--bf-appearance-token-color-text-disabled': colors.text.disabled, + '--bf-appearance-token-color-success': colors.semantic.success, + '--bf-appearance-token-color-success-bg': colors.semantic.successBg, + '--bf-appearance-token-color-success-border': colors.semantic.successBorder, + '--bf-appearance-token-color-warning': colors.semantic.warning, + '--bf-appearance-token-color-warning-bg': colors.semantic.warningBg, + '--bf-appearance-token-color-warning-border': colors.semantic.warningBorder, + '--bf-appearance-token-color-error': colors.semantic.error, + '--bf-appearance-token-color-error-bg': colors.semantic.errorBg, + '--bf-appearance-token-color-error-border': colors.semantic.errorBorder, + '--bf-appearance-token-color-info': colors.semantic.info, + '--bf-appearance-token-color-info-bg': colors.semantic.infoBg, + '--bf-appearance-token-color-info-border': colors.semantic.infoBorder, + '--bf-appearance-token-border-subtle': colors.border.subtle, + '--bf-appearance-token-border-base': colors.border.base, + '--bf-appearance-token-border-medium': colors.border.medium, + '--bf-appearance-token-border-strong': colors.border.strong, + '--bf-appearance-token-border-prominent': colors.border.prominent, + '--bf-appearance-token-border-accent-soft': `color-mix(in srgb, ${colors.accent[600]} 30%, transparent)`, + '--bf-appearance-token-border-accent-subtle': `color-mix(in srgb, ${colors.accent[600]} 15%, transparent)`, + '--bf-appearance-token-border-accent': `color-mix(in srgb, ${colors.accent[600]} 40%, transparent)`, + '--bf-appearance-token-border-accent-strong': `color-mix(in srgb, ${colors.accent[600]} 60%, transparent)`, + '--bf-appearance-token-scene-viewport-border-width': palette.layout?.sceneViewportBorder === false ? '0' : '1px', + '--bf-appearance-token-element-bg-subtle': colors.element.subtle, + '--bf-appearance-token-element-bg-soft': colors.element.soft, + '--bf-appearance-token-element-bg-base': colors.element.base, + '--bf-appearance-token-element-bg-medium': colors.element.medium, + '--bf-appearance-token-element-bg-strong': colors.element.strong, + '--bf-appearance-token-element-bg-hover': colors.element.medium, + '--bf-appearance-token-git-color-branch': colors.git.branch, + '--bf-appearance-token-git-color-branch-bg': colors.git.branchBg, + '--bf-appearance-token-git-color-branch-bg-hover': colors.element.medium, + '--bf-appearance-token-git-color-changes': colors.git.changes, + '--bf-appearance-token-git-color-added': colors.git.added, + '--bf-appearance-token-git-color-deleted': colors.git.deleted, + '--bf-appearance-token-git-color-staged': colors.git.staged, + '--bf-appearance-token-scrollbar-thumb': colors.scrollbar?.thumb ?? (palette.type === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.15)'), + '--bf-appearance-token-scrollbar-thumb-hover': colors.scrollbar?.thumbHover ?? (palette.type === 'dark' ? 'rgba(255, 255, 255, 0.24)' : 'rgba(0, 0, 0, 0.3)'), + '--bf-appearance-token-opacity-disabled': String(effects.opacity.disabled), + '--bf-appearance-token-opacity-hover': String(effects.opacity.hover), + '--bf-appearance-token-badge-padding-y': '2px', + '--bf-appearance-token-button-bg-hover': `linear-gradient(135deg, color-mix(in srgb, ${colors.accent[600]} 15%, transparent) 0%, color-mix(in srgb, ${colors.accent[500]} 12%, transparent) 30%, color-mix(in srgb, ${colors.accent[600]} 8%, transparent) 60%, color-mix(in srgb, ${colors.accent[600]} 18%, transparent) 100%)`, + '--bf-appearance-token-glow-shadow-blue': `0 12px 32px color-mix(in srgb, ${colors.accent[600]} 25%, transparent), 0 6px 16px color-mix(in srgb, ${colors.accent[500]} 18%, transparent), 0 3px 8px rgba(0, 0, 0, 0.12)`, + '--bf-appearance-token-inner-glow-top': 'inset 0 1px 0 rgba(255, 255, 255, 0.08)', + '--bf-appearance-token-inner-glow-top-hover': 'inset 0 1px 0 rgba(255, 255, 255, 0.24)', + '--bf-appearance-token-glass-blue-subtle': `color-mix(in srgb, ${colors.accent[600]} 8%, transparent)`, + '--bf-appearance-token-glass-red-subtle': `color-mix(in srgb, ${colors.semantic.error} 5%, transparent)`, + '--bf-appearance-token-glass-red-base': colors.semantic.errorBg, + '--bf-appearance-token-glass-red-hover': `color-mix(in srgb, ${colors.semantic.error} 15%, transparent)`, + '--bf-appearance-token-glass-bg-base': `linear-gradient(135deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.04) 50%, rgba(255, 255, 255, 0.04) 100%)`, + '--bf-appearance-token-glass-bg-hover': colors.element.medium, + '--bf-appearance-token-glass-bg-active': colors.element.strong, + '--bf-appearance-token-glass-border-base': colors.border.base, + '--bf-appearance-token-glass-border-hover': colors.border.medium, + '--bf-appearance-token-color-indigo-500': '#6366f1', + '--bf-appearance-token-color-cyan-500': '#06b6d4', + '--bf-appearance-token-domain-context-compression': purple[500], + '--bf-appearance-token-domain-generative-ui': '#06b6d4', + '--bf-appearance-token-domain-mini-app': purple[500], + '--bf-appearance-token-domain-mermaid-diagram': colors.semantic.success, + '--bf-appearance-token-domain-tool-search': colors.accent[600], + '--bf-appearance-token-domain-tool-web-search': '#06b6d4', + '--bf-appearance-token-domain-tool-git': colors.semantic.warning, + '--bf-appearance-token-domain-tool-terminal': '#14b8a6', + '--bf-appearance-token-domain-tool-mcp': purple[500], + '--bf-appearance-token-domain-tool-assistant-action': purple[500], + '--bf-appearance-token-domain-tool-review-summary': '#06b6d4', + '--bf-appearance-token-domain-capability-docs': colors.semantic.success, + '--bf-appearance-token-domain-capability-testing': colors.semantic.warning, + '--bf-appearance-token-domain-capability-creative': purple[500], + '--bf-appearance-token-domain-capability-ops': '#06b6d4', + '--bf-appearance-token-domain-insights-positive': colors.semantic.success, + '--bf-appearance-token-domain-insights-time': purple[500], + '--bf-appearance-token-domain-insights-neutral': colors.semantic.warning, + '--bf-appearance-token-domain-insights-issue': colors.semantic.error, + '--bf-appearance-token-domain-progress-compacting': '#14b8a6', + '--bf-appearance-token-domain-template-memories': purple[500], + '--bf-appearance-token-domain-review-member-default': '#64748b', + '--bf-appearance-token-domain-review-worker': colors.accent[600], + '--bf-appearance-token-domain-review-judge': purple[500], + '--bf-appearance-token-domain-teal-action': '#14b8a6', + '--bf-appearance-token-domain-todo': '#14b8a6', + '--bf-appearance-token-domain-git-lane-0': colors.accent[600], + '--bf-appearance-token-domain-git-lane-1': colors.semantic.success, + '--bf-appearance-token-domain-git-lane-2': colors.semantic.warning, + '--bf-appearance-token-domain-git-lane-3': purple[500], + '--bf-appearance-token-domain-git-lane-4': colors.semantic.error, + '--bf-appearance-token-domain-git-lane-5': '#06b6d4', + '--bf-appearance-token-domain-git-lane-6': '#14b8a6', + '--bf-appearance-token-domain-git-lane-7': '#64748b', + '--bf-appearance-token-domain-text-stroke-0': colors.semantic.warning, + '--bf-appearance-token-domain-text-stroke-1': colors.semantic.error, + '--bf-appearance-token-domain-text-stroke-2': colors.accent[600], + '--bf-appearance-token-domain-text-stroke-3': '#06b6d4', + '--bf-appearance-token-domain-text-stroke-4': purple[500], + '--bf-appearance-token-domain-inspector-active-border': colors.accent[600], + '--bf-appearance-token-domain-inspector-active-background': `color-mix(in srgb, ${colors.accent[600]} 15%, transparent)`, + '--bf-appearance-token-domain-inspector-active-border-subtle': `color-mix(in srgb, ${colors.accent[600]} 40%, transparent)`, + '--bf-appearance-token-domain-inspector-selected-border': colors.semantic.success, + '--bf-appearance-token-domain-inspector-selected-background': `color-mix(in srgb, ${colors.semantic.success} 18%, transparent)`, + '--bf-appearance-token-domain-inspector-browser-tooltip-background': 'rgba(10, 10, 10, 0.92)', + '--bf-appearance-token-domain-inspector-main-tooltip-background': 'rgba(15, 23, 42, 0.95)', + '--bf-appearance-token-domain-inspector-tooltip-text': '#e2e8f0', + '--bf-appearance-token-domain-inspector-tooltip-shadow': 'rgba(0, 0, 0, 0.5)', + '--bf-appearance-token-language-blue': '#3178c6', + '--bf-appearance-token-language-cyan': '#00add8', + '--bf-appearance-token-language-yellow': '#f7df1e', + '--bf-appearance-token-language-orange': '#e38c00', + '--bf-appearance-token-language-red': colors.semantic.error, + '--bf-appearance-token-language-green': colors.semantic.success, + '--bf-appearance-token-language-purple': purple[500], + '--bf-appearance-token-language-slate': '#64748b', + '--bf-appearance-token-prism-light-foreground': '#24292f', + '--bf-appearance-token-prism-light-comment': '#6e7781', + '--bf-appearance-token-prism-light-keyword': '#cf222e', + '--bf-appearance-token-prism-light-string': '#0550ae', + '--bf-appearance-token-prism-light-function': '#8250df', + '--bf-appearance-token-prism-light-number': '#0550ae', + '--bf-appearance-token-prism-light-tag': '#116329', + '--bf-appearance-token-prism-light-punctuation': '#57606a', + '--bf-appearance-token-prism-light-property': '#953800', + '--bf-appearance-token-prism-dark-foreground': '#d4d4d4', + '--bf-appearance-token-prism-dark-comment': '#6a9955', + '--bf-appearance-token-prism-dark-keyword': '#c586c0', + '--bf-appearance-token-prism-dark-string': '#ce9178', + '--bf-appearance-token-prism-dark-function': '#dcdcaa', + '--bf-appearance-token-prism-dark-number': '#b5cea8', + '--bf-appearance-token-prism-dark-tag': '#569cd6', + '--bf-appearance-token-prism-dark-punctuation': '#d4d4d4', + '--bf-appearance-token-prism-dark-property': '#9cdcfe', + '--bf-appearance-token-blur-medium': 'blur(12px) saturate(1.2)', + '--bf-appearance-token-blur-subtle': effects.blur.subtle, + '--bf-appearance-token-blur-base': effects.blur.base, + '--bf-appearance-token-motion-lazy': '1s', + '--bf-appearance-token-motion-instant': motion.duration.instant, + '--bf-appearance-token-easing-accelerate': 'cubic-bezier(0.4, 0, 1, 1)', + '--bf-appearance-token-easing-decelerate': motion.easing.decelerate, + '--bf-appearance-token-easing-smooth': motion.easing.smooth, + '--bf-appearance-token-font-weight-bold': String(typography.weight.semibold), + '--bf-appearance-token-font-weight-normal': String(typography.weight.normal), + '--bf-appearance-token-font-size-3xl': typography.size['3xl'], + '--bf-appearance-token-line-height-tight': String(typography.lineHeight.tight), + '--bf-appearance-token-line-height-base': String(typography.lineHeight.base), + '--bf-appearance-token-line-height-relaxed': String(typography.lineHeight.relaxed), + '--bf-appearance-token-flowchat-font-size-xxs': '10px', + '--bf-appearance-token-flowchat-font-size-2xs': '11px', + '--bf-appearance-token-flowchat-font-size-xs': typography.size.xs, + '--bf-appearance-token-flowchat-font-size-sm': typography.size.sm, + '--bf-appearance-token-flowchat-font-size-base': typography.size.base, + '--bf-appearance-token-flowchat-font-size-lg': typography.size.lg, + '--bf-appearance-token-flowchat-font-size-xl': typography.size.xl, + '--bf-appearance-token-flowchat-font-size-2xl': typography.size['2xl'], + '--bf-appearance-token-flowchat-font-size-3xl': typography.size['3xl'], + '--bf-appearance-token-flowchat-text-line-height': '1.58', + '--bf-appearance-token-flowchat-support-line-height': '1.45', + '--bf-appearance-token-flowchat-compact-line-height': '1.32', + '--bf-appearance-token-flowchat-flow-item-gap': '0.42rem', + '--bf-appearance-token-flowchat-turn-gap': '0.46rem', + '--bf-appearance-token-flowchat-inline-gap': '0.35rem', + '--bf-appearance-token-flowchat-control-gap': '0.5rem', + '--bf-appearance-token-flowchat-control-pad-y': '0.35rem', + '--bf-appearance-token-flowchat-control-pad-x': '0.75rem', + '--bf-appearance-token-flowchat-content-inline-pad': '3rem', + '--bf-appearance-token-flowchat-content-inline-pad-mobile': '1.5rem', + '--bf-appearance-token-flowchat-card-gap': '0.42rem', + '--bf-appearance-token-flowchat-card-radius': effects.radius.base, + '--bf-appearance-token-flowchat-card-pad-y': '0.625rem', + '--bf-appearance-token-flowchat-card-pad-x': '0.75rem', + '--bf-appearance-token-flowchat-card-expanded-pad-y': '0.5rem', + '--bf-appearance-token-flowchat-card-expanded-pad-x': '0.625rem', + '--bf-appearance-token-flowchat-code-font-size': typography.size.sm, + '--bf-appearance-token-flowchat-code-line-height': '1.52', + '--bf-appearance-token-flowchat-code-block-pad-y': '0.55rem', + '--bf-appearance-token-flowchat-code-block-pad-x': '0.75rem', + '--bf-appearance-token-flowchat-link-color': palette.type === 'dark' ? '#60a5fa' : '#0969da', + '--bf-appearance-token-flowchat-link-hover-color': palette.type === 'dark' ? '#93c5fd' : '#0550ae', + '--bf-appearance-token-tool-card-font-mono': typography.font.mono, + '--bf-appearance-token-tool-card-header-pad-y': '0.44rem', + '--bf-appearance-token-tool-card-header-pad-right': '0.625rem', + '--bf-appearance-token-tool-card-header-icon-slot': '34px', + '--bf-appearance-token-tool-card-expanded-pad-x': '0.625rem', + '--bf-appearance-token-tool-card-action-font-size': typography.size.sm, + '--bf-appearance-token-tool-card-action-line-height': '1.45', + }; + + const overlays = { + '--bf-appearance-token-color-overlay-white-04': 'rgba(255, 255, 255, 0.04)', + '--bf-appearance-token-color-overlay-white-08': 'rgba(255, 255, 255, 0.08)', + '--bf-appearance-token-color-overlay-white-12': 'rgba(255, 255, 255, 0.12)', + '--bf-appearance-token-color-overlay-white-15': 'rgba(255, 255, 255, 0.15)', + '--bf-appearance-token-color-overlay-white-20': 'rgba(255, 255, 255, 0.2)', + '--bf-appearance-token-color-overlay-white-24': 'rgba(255, 255, 255, 0.24)', + '--bf-appearance-token-color-overlay-white-60': 'rgba(255, 255, 255, 0.6)', + '--bf-appearance-token-color-overlay-white-70': 'rgba(255, 255, 255, 0.7)', + '--bf-appearance-token-color-overlay-white-80': 'rgba(255, 255, 255, 0.8)', + '--bf-appearance-token-color-overlay-white-90': 'rgba(255, 255, 255, 0.9)', + '--bf-appearance-token-color-overlay-white-95': 'rgba(255, 255, 255, 0.95)', + '--bf-appearance-token-color-overlay-black-08': 'rgba(0, 0, 0, 0.08)', + '--bf-appearance-token-color-overlay-black-12': 'rgba(0, 0, 0, 0.12)', + '--bf-appearance-token-color-overlay-black-15': 'rgba(0, 0, 0, 0.15)', + '--bf-appearance-token-color-overlay-black-20': 'rgba(0, 0, 0, 0.2)', + '--bf-appearance-token-color-overlay-black-30': 'rgba(0, 0, 0, 0.3)', + '--bf-appearance-token-color-overlay-black-40': 'rgba(0, 0, 0, 0.4)', + '--bf-appearance-token-color-overlay-black-50': 'rgba(0, 0, 0, 0.5)', + '--bf-appearance-token-color-overlay-black-80': 'rgba(0, 0, 0, 0.8)', + '--bf-appearance-token-color-overlay-slate-04': 'rgba(15, 23, 42, 0.04)', + '--bf-appearance-token-color-overlay-slate-08': 'rgba(15, 23, 42, 0.08)', + '--bf-appearance-token-color-overlay-slate-12': 'rgba(15, 23, 42, 0.12)', + }; + Object.assign(tokens, overlays); + Object.entries(purple).forEach(([key, value]) => { tokens[`--bf-appearance-token-color-purple-${key}`] = value; }); + Object.entries(effects.shadow).forEach(([key, value]) => { tokens[`--bf-appearance-token-shadow-${key}`] = value; }); + Object.entries(effects.blur).forEach(([key, value]) => { tokens[`--bf-appearance-token-blur-${key}`] = value; }); + Object.entries(effects.radius).forEach(([key, value]) => { tokens[`--bf-appearance-token-size-radius-${key}`] = value; }); + tokens['--bf-appearance-token-size-radius-md'] = effects.radius.base; + Object.entries(effects.spacing).forEach(([key, value]) => { tokens[`--bf-appearance-token-size-gap-${key}`] = value; }); + Object.entries(motion.duration).forEach(([key, value]) => { tokens[`--bf-appearance-token-motion-${key}`] = value; }); + Object.entries(motion.easing).forEach(([key, value]) => { tokens[`--bf-appearance-token-easing-${key}`] = value; }); + tokens['--bf-appearance-token-font-family-sans'] = typography.font.sans; + tokens['--bf-appearance-token-font-family-mono'] = typography.font.mono; + Object.entries(typography.weight).forEach(([key, value]) => { tokens[`--bf-appearance-token-font-weight-${key}`] = String(value); }); + Object.entries(typography.size).forEach(([key, value]) => { tokens[`--bf-appearance-token-font-size-${key}`] = value; }); + Object.entries(typography.lineHeight).forEach(([key, value]) => { tokens[`--bf-appearance-token-line-height-${key}`] = String(value); }); + + const button = palette.components?.button; + tokens['--bf-appearance-token-btn-primary-bg'] = button?.primary.default.background ?? colors.accent[200]; + tokens['--bf-appearance-token-btn-primary-color'] = button?.primary.default.color ?? colors.accent[600]; + tokens['--bf-appearance-token-btn-primary-border'] = button?.primary.default.border ?? 'transparent'; + tokens['--bf-appearance-token-btn-primary-shadow'] = button?.primary.default.shadow ?? 'none'; + tokens['--bf-appearance-token-btn-primary-hover-bg'] = button?.primary.hover.background ?? colors.accent[300]; + tokens['--bf-appearance-token-btn-primary-hover-color'] = button?.primary.hover.color ?? colors.text.primary; + tokens['--bf-appearance-token-btn-primary-hover-border'] = button?.primary.hover.border ?? 'transparent'; + tokens['--bf-appearance-token-btn-primary-hover-shadow'] = button?.primary.hover.shadow ?? 'none'; + tokens['--bf-appearance-token-btn-primary-hover-transform'] = button?.primary.hover.transform ?? 'none'; + tokens['--bf-appearance-token-btn-primary-active-bg'] = button?.primary.active.background ?? colors.accent[200]; + tokens['--bf-appearance-token-btn-primary-active-color'] = button?.primary.active.color ?? colors.text.primary; + tokens['--bf-appearance-token-btn-primary-active-border'] = button?.primary.active.border ?? 'transparent'; + tokens['--bf-appearance-token-btn-primary-active-shadow'] = button?.primary.active.shadow ?? 'none'; + tokens['--bf-appearance-token-btn-primary-active-transform'] = button?.primary.active.transform ?? 'none'; + tokens['--bf-appearance-token-btn-ghost-color'] = button?.ghost.default.color ?? colors.text.muted; + tokens['--bf-appearance-token-btn-ghost-hover-bg'] = button?.ghost.hover.background ?? colors.element.subtle; + tokens['--bf-appearance-token-btn-ghost-hover-color'] = button?.ghost.hover.color ?? colors.text.primary; + tokens['--bf-appearance-token-btn-ghost-hover-border'] = button?.ghost.hover.border ?? 'transparent'; + return tokens; +} + +function createWidgetAppearanceVars( + cssTokens: Readonly>, +): Record { + return Object.fromEntries(WIDGET_APPEARANCE_VARIABLE_NAMES.map(name => { + const value = cssTokens[name]; + if (!value) throw new Error(`Builtin Appearance does not define Widget token ${name}`); + return [name, value]; + })) as Record; +} + +export function buildBuiltinAppearance(palette: AppearancePalette): AppearancePackage { + const cssTokens = createCssTokens(palette); + const primaryButton = palette.components?.button?.primary; + const ghostButton = palette.components?.button?.ghost; + const purple = palette.colors.purple ?? { + 100: palette.colors.element.subtle, + 200: palette.colors.element.soft, + 500: palette.colors.accent[500], + 600: palette.colors.accent[600], + }; + const colors = { + 'bg-primary': color(palette.colors.background.primary), + 'bg-elevated': color(palette.colors.background.elevated), + 'text-primary': color(palette.colors.text.primary), + 'text-secondary': color(palette.colors.text.secondary), + 'text-muted': color(palette.colors.text.muted), + 'accent': color(palette.colors.accent[500]), + 'accent-active': color(palette.colors.accent[600]), + 'border-subtle': color(palette.colors.border.subtle), + 'border-base': color(palette.colors.border.base), + 'border-strong': color(palette.colors.border.strong), + 'element-subtle': color(palette.colors.element.subtle), + 'element-soft': color(palette.colors.element.soft), + 'element-base': color(palette.colors.element.base), + 'element-medium': color(palette.colors.element.medium), + 'error': color(palette.colors.semantic.error), + 'error-bg': color(palette.colors.semantic.errorBg), + 'error-border': color(palette.colors.semantic.errorBorder), + 'success': color(palette.colors.semantic.success), + 'success-bg': color(palette.colors.semantic.successBg), + 'purple': color(purple[500]), + 'purple-bg': color(purple[100]), + 'button-primary-bg': color(primaryButton?.default.background ?? palette.colors.element.base), + 'button-primary-fg': color(primaryButton?.default.color ?? palette.colors.text.primary), + 'button-primary-border': color(primaryButton?.default.border ?? palette.colors.border.base), + 'button-primary-hover-bg': color(primaryButton?.hover.background ?? palette.colors.element.medium), + 'button-primary-hover-fg': color(primaryButton?.hover.color ?? palette.colors.text.primary), + 'button-primary-hover-border': color(primaryButton?.hover.border ?? palette.colors.border.medium), + 'button-primary-active-bg': color(primaryButton?.active.background ?? palette.colors.element.soft), + 'button-primary-active-fg': color(primaryButton?.active.color ?? palette.colors.text.primary), + 'button-primary-active-border': color(primaryButton?.active.border ?? palette.colors.border.strong), + 'button-ghost-hover-bg': color(ghostButton?.hover.background ?? palette.colors.element.soft), + 'button-ghost-hover-fg': color(ghostButton?.hover.color ?? palette.colors.text.primary), + }; + const monaco = palette.monaco; + const xtermAnsi = palette.type === 'dark' ? { + black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510', blue: '#2472c8', + magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5', brightBlack: '#666666', + brightRed: '#f14c4c', brightGreen: '#23d18b', brightYellow: '#f5f543', brightBlue: '#3b8eea', + brightMagenta: '#d670d6', brightCyan: '#29b8db', brightWhite: '#e5e5e5', + } : { + black: '#000000', red: '#cd3131', green: '#107C10', yellow: '#949800', blue: '#0451a5', + magenta: '#bc05bc', cyan: '#0598bc', white: '#555555', brightBlack: '#666666', + brightRed: '#cd3131', brightGreen: '#14CE14', brightYellow: '#b5ba00', brightBlue: '#0451a5', + brightMagenta: '#bc05bc', brightCyan: '#0598bc', brightWhite: '#a5a5a5', + }; + const monacoColors: Record = { + 'editor.background': monaco?.colors.background ?? palette.colors.background.scene, + 'editor.foreground': monaco?.colors.foreground ?? palette.colors.text.primary, + 'editorLineNumber.foreground': palette.colors.text.muted, + 'editorCursor.foreground': monaco?.colors.cursor ?? palette.colors.accent[500], + 'editor.selectionBackground': monaco?.colors.selection ?? palette.colors.accent[300], + 'editor.inactiveSelectionBackground': palette.colors.accent[200], + 'editor.selectionHighlightBackground': palette.colors.accent[200], + 'editor.selectionHighlightBorder': palette.colors.accent[400], + 'editor.wordHighlightBackground': palette.colors.accent[100], + 'editor.wordHighlightStrongBackground': palette.colors.accent[200], + 'editor.lineHighlightBackground': monaco?.colors.lineHighlight ?? palette.colors.background.secondary, + focusBorder: '#00000000', + contrastBorder: '#00000000', + 'diffEditor.insertedTextBorder': '#00000000', + 'diffEditor.removedTextBorder': '#00000000', + }; + Object.entries(monaco?.colors ?? {}).forEach(([key, value]) => { + if (!['background', 'foreground', 'lineHighlight', 'selection', 'cursor'].includes(key)) { + monacoColors[key] = value; + } + }); + + return { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: palette.id, + name: palette.name, + version: /^\d+\.\d+\.\d+/.test(palette.version ?? '') ? palette.version! : '1.0.0', + mode: palette.type, + requiredCapabilities: ['components.v1', 'renderers.v1'], + globals: { + colors, + lengths: { + 'border-one': { kind: 'px', value: 1 }, + 'radius-sm': length(palette.effects.radius.sm), + 'radius-base': length(palette.effects.radius.base), + 'radius-lg': length(palette.effects.radius.lg), + 'gap-1': length(palette.effects.spacing[1]), + 'gap-2': length(palette.effects.spacing[2]), + 'gap-3': length(palette.effects.spacing[3]), + 'gap-4': length(palette.effects.spacing[4]), + 'gap-6': length(palette.effects.spacing[6]), + 'font-xs': length(palette.typography.size.xs), + 'font-sm': length(palette.typography.size.sm), + 'font-base': length(palette.typography.size.base), + 'font-lg': length(palette.typography.size.lg), + }, + numbers: { + 'opacity-disabled': number(palette.effects.opacity.disabled), + 'weight-medium': number(palette.typography.weight.medium), + 'weight-semibold': number(palette.typography.weight.semibold), + 'line-tight': number(palette.typography.lineHeight.tight), + 'line-base': number(palette.typography.lineHeight.base), + 'line-relaxed': number(palette.typography.lineHeight.relaxed), + }, + durations: { + fast: duration(palette.motion.duration.fast), + base: duration(palette.motion.duration.base), + }, + easings: { + standard: { kind: 'easing', value: 'standard' }, + }, + fontFamilies: { + sans: fontFamily(palette.typography.font.sans), + }, + }, + materials: { + control: { + visualRole: 'control', + style: { + backgroundColor: colorRef('element-base'), + color: colorRef('text-secondary'), + borderRadius: lengthRef('radius-sm'), + fontFamily: fontRef('sans'), + transition: transition(), + }, + }, + surface: { + visualRole: 'card', + style: { + backgroundColor: colorRef('element-subtle'), + color: colorRef('text-secondary'), + borderRadius: lengthRef('radius-base'), + transition: transition(), + }, + }, + }, + components: { + button: { + parts: { + root: { + materials: ['control'], + base: { + borderStyle: 'solid', + borderWidth: { kind: 'zero' }, + fontWeight: numberRef('weight-medium'), + gap: lengthRef('gap-2'), + cursor: 'pointer', + }, + facets: { + variant: { + primary: { + backgroundColor: colorRef('button-primary-bg'), + color: colorRef('button-primary-fg'), + borderColor: colorRef('button-primary-border'), + borderWidth: lengthRef('border-one'), + }, + accent: { + backgroundColor: colorRef('button-primary-bg'), + color: colorRef('button-primary-fg'), + borderColor: colorRef('button-primary-border'), + borderWidth: lengthRef('border-one'), + }, + secondary: { backgroundColor: colorRef('element-base') }, + ghost: { backgroundColor: { kind: 'transparent' } }, + dashed: { + backgroundColor: { kind: 'transparent' }, + borderColor: colorRef('border-base'), + borderStyle: 'dashed', + borderWidth: lengthRef('border-one'), + }, + danger: { backgroundColor: colorRef('error-bg'), color: colorRef('error') }, + success: { backgroundColor: colorRef('success-bg'), color: colorRef('success') }, + ai: { backgroundColor: colorRef('purple-bg'), color: colorRef('purple') }, + }, + size: { + small: { height: { kind: 'px', value: 32 }, paddingInline: lengthRef('gap-3'), fontSize: lengthRef('font-sm') }, + medium: { height: { kind: 'px', value: 40 }, paddingInline: lengthRef('gap-4'), fontSize: lengthRef('font-base') }, + large: { height: { kind: 'px', value: 48 }, paddingInline: lengthRef('gap-6'), fontSize: lengthRef('font-lg') }, + }, + iconOnly: { + true: { paddingInline: { kind: 'zero' } }, + }, + }, + states: { + hover: { backgroundColor: colorRef('element-medium'), color: colorRef('text-primary') }, + active: { backgroundColor: colorRef('element-soft') }, + focusVisible: { outlineColor: colorRef('accent'), outlineWidth: { kind: 'px', value: 2 }, outlineOffset: { kind: 'px', value: 1 } }, + disabled: { opacity: numberRef('opacity-disabled'), cursor: 'default' }, + loading: { cursor: 'wait' }, + }, + contexts: [ + { when: { facets: { variant: 'primary' }, states: ['hover'] }, style: { backgroundColor: colorRef('button-primary-hover-bg'), color: colorRef('button-primary-hover-fg'), borderColor: colorRef('button-primary-hover-border') } }, + { when: { facets: { variant: 'accent' }, states: ['hover'] }, style: { backgroundColor: colorRef('button-primary-hover-bg'), color: colorRef('button-primary-hover-fg'), borderColor: colorRef('button-primary-hover-border') } }, + { when: { facets: { variant: 'primary' }, states: ['active'] }, style: { backgroundColor: colorRef('button-primary-active-bg'), color: colorRef('button-primary-active-fg'), borderColor: colorRef('button-primary-active-border') } }, + { when: { facets: { variant: 'accent' }, states: ['active'] }, style: { backgroundColor: colorRef('button-primary-active-bg'), color: colorRef('button-primary-active-fg'), borderColor: colorRef('button-primary-active-border') } }, + { when: { facets: { variant: 'ghost' }, states: ['hover'] }, style: { backgroundColor: colorRef('button-ghost-hover-bg'), color: colorRef('button-ghost-hover-fg') } }, + { when: { facets: { size: 'small', iconOnly: 'true' } }, style: { width: { kind: 'px', value: 32 } } }, + { when: { facets: { size: 'medium', iconOnly: 'true' } }, style: { width: { kind: 'px', value: 40 } } }, + { when: { facets: { size: 'large', iconOnly: 'true' } }, style: { width: { kind: 'px', value: 48 } } }, + ], + }, + loadingIcon: { + base: { width: { kind: 'px', value: 16 }, height: { kind: 'px', value: 16 }, borderRadius: { kind: 'percent', value: 50 } }, + }, + loadingText: { base: { color: colorRef('text-primary') } }, + }, + }, + card: { + parts: { + root: { + materials: ['surface'], + base: { display: 'flex', flexDirection: 'column' }, + facets: { + variant: { + default: { backgroundColor: colorRef('element-subtle') }, + elevated: { backgroundColor: colorRef('element-soft') }, + subtle: { backgroundColor: { kind: 'transparent' } }, + accent: { backgroundColor: colorRef('element-soft') }, + purple: { backgroundColor: colorRef('purple-bg') }, + }, + padding: { + none: { padding: { kind: 'zero' } }, + small: { padding: lengthRef('gap-2') }, + medium: { padding: lengthRef('gap-4') }, + large: { padding: lengthRef('gap-6') }, + }, + radius: { + small: { borderRadius: lengthRef('radius-sm') }, + medium: { borderRadius: lengthRef('radius-base') }, + large: { borderRadius: lengthRef('radius-lg') }, + }, + }, + states: { + fullWidth: { width: { kind: 'percent', value: 100 } }, + }, + contexts: [ + { when: { states: ['interactive', 'hover'] }, style: { backgroundColor: colorRef('element-soft'), cursor: 'pointer' } }, + { when: { states: ['interactive', 'active'] }, style: { backgroundColor: colorRef('element-base') } }, + ], + }, + header: { base: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: lengthRef('gap-3') } }, + title: { base: { color: colorRef('text-primary'), fontSize: lengthRef('font-sm'), fontWeight: numberRef('weight-semibold'), lineHeight: numberRef('line-tight') } }, + subtitle: { base: { color: colorRef('text-muted'), fontSize: lengthRef('font-xs'), lineHeight: numberRef('line-base') } }, + body: { base: { color: colorRef('text-secondary'), fontSize: lengthRef('font-sm'), lineHeight: numberRef('line-relaxed') } }, + footer: { base: { display: 'flex', alignItems: 'center', gap: lengthRef('gap-2') }, facets: { align: { left: { justifyContent: 'flex-start' }, center: { justifyContent: 'center' }, right: { justifyContent: 'flex-end' }, between: { justifyContent: 'space-between' } } } }, + }, + }, + input: { + parts: { + root: { base: { display: 'flex', flexDirection: 'column', gap: { kind: 'px', value: 6 } }, states: { disabled: { opacity: numberRef('opacity-disabled'), cursor: 'default' } } }, + label: { base: { color: colorRef('text-secondary'), fontSize: lengthRef('font-sm'), fontWeight: numberRef('weight-medium') } }, + container: { + base: { display: 'flex', alignItems: 'center', gap: lengthRef('gap-2'), paddingInline: lengthRef('gap-3'), borderRadius: { kind: 'px', value: 4 }, borderStyle: 'solid', borderWidth: lengthRef('border-one'), transition: transition() }, + facets: { + variant: { + default: { backgroundColor: { kind: 'transparent' }, borderColor: colorRef('border-base') }, + filled: { backgroundColor: colorRef('element-subtle'), borderColor: { kind: 'transparent' } }, + outlined: { backgroundColor: { kind: 'transparent' }, borderColor: colorRef('border-strong') }, + }, + size: { + small: { height: { kind: 'px', value: 28 }, fontSize: { kind: 'px', value: 13 } }, + medium: { height: { kind: 'px', value: 36 }, fontSize: { kind: 'px', value: 14 } }, + large: { height: { kind: 'px', value: 44 }, fontSize: { kind: 'px', value: 15 } }, + }, + }, + states: { + hover: { backgroundColor: colorRef('element-subtle'), borderColor: colorRef('border-strong') }, + focusWithin: { backgroundColor: { kind: 'transparent' }, borderColor: colorRef('accent') }, + error: { borderColor: colorRef('error-border') }, + }, + }, + control: { base: { color: colorRef('text-primary'), fontFamily: fontRef('sans') } }, + prefix: { base: { color: colorRef('text-muted') } }, + suffix: { base: { color: colorRef('text-muted') } }, + message: { base: { color: colorRef('error'), fontSize: lengthRef('font-xs') } }, + }, + }, + modal: { + parts: { + overlay: { base: { backgroundColor: { kind: 'rgb', r: 0, g: 0, b: 0, a: 0.55 }, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: { kind: 'px', value: 24 } } }, + dialog: { + base: { backgroundColor: colorRef('bg-primary'), borderColor: colorRef('border-subtle'), borderStyle: 'solid', borderWidth: lengthRef('border-one'), borderRadius: { kind: 'px', value: 8 }, maxHeight: { kind: 'percent', value: 100 } }, + facets: { + size: { + small: { width: { kind: 'percent', value: 100 }, maxWidth: { kind: 'px', value: 420 } }, + medium: { width: { kind: 'percent', value: 100 }, maxWidth: { kind: 'px', value: 560 } }, + large: { width: { kind: 'percent', value: 100 }, maxWidth: { kind: 'px', value: 600 } }, + xlarge: { width: { kind: 'percent', value: 100 }, maxWidth: { kind: 'px', value: 720 } }, + }, + }, + }, + header: { base: { display: 'flex', alignItems: 'center', gap: { kind: 'px', value: 6 }, padding: { kind: 'px', value: 8 }, borderWidth: { kind: 'zero' }, borderBottomColor: colorRef('border-subtle'), borderBottomWidth: lengthRef('border-one'), borderStyle: 'solid' } }, + title: { base: { color: colorRef('text-secondary'), fontSize: { kind: 'px', value: 12 }, fontWeight: numberRef('weight-medium') } }, + close: { base: { backgroundColor: { kind: 'transparent' }, color: colorRef('text-muted'), borderRadius: { kind: 'px', value: 6 }, cursor: 'pointer' }, states: { hover: { backgroundColor: colorRef('element-subtle'), color: colorRef('text-primary') }, focusVisible: { outlineColor: colorRef('accent'), outlineWidth: { kind: 'px', value: 2 }, outlineOffset: { kind: 'px', value: 2 } } } }, + content: { base: { color: colorRef('text-secondary'), overflowY: 'auto', overflowX: 'hidden' }, states: { contentInset: { paddingInline: { kind: 'px', value: 28 } } } }, + resizeHandle: { states: { hover: { backgroundColor: colorRef('element-subtle') } } }, + }, + }, + }, + renderers: { + 'css-tokens': { + version: 1, + settings: { + tokens: cssTokens, + background: palette.colors.background.primary, + }, + }, + monaco: { + version: 1, + settings: { + id: `bf-appearance-${palette.id}`, + base: monaco?.base ?? (palette.type === 'dark' ? 'vs-dark' : 'vs'), + inherit: monaco?.inherit ?? true, + rules: monaco?.rules ?? [], + colors: monacoColors, + }, + }, + xterm: { + version: 1, + settings: { + surfaces: { + terminal: { + background: palette.colors.background.scene, + foreground: palette.colors.text.primary, + cursor: palette.colors.text.primary, + cursorAccent: palette.colors.background.secondary, + selectionBackground: palette.type === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(173, 214, 255, 0.45)', + selectionInactiveBackground: palette.type === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(173, 214, 255, 0.25)', + ...xtermAnsi, + }, + output: { + background: palette.colors.background.scene, + foreground: palette.colors.text.primary, + cursor: 'transparent', + cursorAccent: 'transparent', + selectionBackground: palette.type === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(173, 214, 255, 0.45)', + selectionInactiveBackground: palette.type === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(173, 214, 255, 0.25)', + ...xtermAnsi, + }, + }, + fontWeight: palette.type === 'dark' ? 'normal' : '500', + fontWeightBold: palette.type === 'dark' ? 'bold' : '700', + }, + }, + mermaid: { + version: 1, + settings: { + mode: palette.type, + palette: { + nodeFill: palette.colors.element.base, + nodeFillHover: palette.colors.element.medium, + nodeText: palette.colors.text.primary, + nodeStroke: palette.colors.border.strong, + nodeStrokeHover: palette.colors.accent[500], + clusterFill: palette.colors.background.secondary, + clusterText: palette.colors.text.secondary, + clusterStroke: palette.colors.border.base, + edgeStroke: palette.colors.text.muted, + edgeLabelBackground: palette.colors.background.elevated, + edgeLabelText: palette.colors.text.secondary, + noteFill: palette.colors.semantic.warningBg, + noteText: palette.colors.text.primary, + noteStroke: palette.colors.semantic.warning, + activationFill: palette.colors.element.medium, + activationStroke: palette.colors.border.strong, + success: palette.colors.semantic.success, + warning: palette.colors.semantic.warning, + error: palette.colors.semantic.error, + errorBackground: palette.colors.semantic.errorBg, + info: palette.colors.accent[500], + highlight: palette.colors.accent[500], + pieColors: [ + palette.colors.accent[500], palette.colors.semantic.success, palette.colors.semantic.warning, + palette.colors.semantic.error, purple[500], palette.colors.accent[400], + palette.colors.accent[600], palette.colors.text.muted, + ], + }, + }, + }, + 'generative-widget': { + version: 1, + settings: { + id: `builtin.${palette.id}`, + mode: palette.type, + vars: createWidgetAppearanceVars(cssTokens), + }, + }, + 'bitfun-canvas': { + version: 1, + settings: { + id: `builtin.${palette.id}`, + mode: palette.type, + bg: palette.colors.background.primary, + panel: palette.colors.background.secondary, + fg: palette.colors.text.primary, + muted: palette.colors.text.muted, + border: palette.colors.border.base, + accent: palette.colors.accent[500], + success: palette.colors.semantic.success, + warning: palette.colors.semantic.warning, + danger: palette.colors.semantic.error, + info: palette.colors.semantic.info, + }, + }, + }, + }; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/catalog.ts b/src/web-ui/src/infrastructure/appearance/builtins/catalog.ts new file mode 100644 index 0000000000..e1af051240 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/catalog.ts @@ -0,0 +1,78 @@ +import { + builtinAppearancePalettes, + DEFAULT_DARK_APPEARANCE_ID, + DEFAULT_LIGHT_APPEARANCE_ID, +} from './palettes'; +import type { AppearanceCatalogEntry, AppearancePackage } from '../types'; +import { buildBuiltinAppearance } from './buildBuiltinAppearance'; +export { WIDGET_APPEARANCE_VARIABLE_NAMES } from '../adapters/widgetAppearanceVariables'; + +export { DEFAULT_DARK_APPEARANCE_ID, DEFAULT_LIGHT_APPEARANCE_ID } from './palettes'; + +export const builtinAppearancePackages: readonly AppearancePackage[] = Object.freeze( + builtinAppearancePalettes.map(palette => Object.freeze(buildBuiltinAppearance(palette))), +); + +const builtinById = new Map(builtinAppearancePackages.map(pkg => [pkg.id, pkg])); + +function collectRendererVariableNames( + rendererId: 'css-tokens' | 'generative-widget', +): readonly string[] { + const names = new Set(); + builtinAppearancePackages.forEach(pkg => { + const values = rendererId === 'css-tokens' + ? pkg.renderers?.['css-tokens']?.settings.tokens + : pkg.renderers?.['generative-widget']?.settings.vars; + if (values && typeof values === 'object' && !Array.isArray(values)) { + Object.keys(values).forEach(name => names.add(name)); + } + }); + return Object.freeze([...names].sort()); +} + +export const APPEARANCE_CSS_TOKEN_NAMES = collectRendererVariableNames('css-tokens'); + +export const builtinAppearanceCatalog: readonly AppearanceCatalogEntry[] = Object.freeze( + builtinAppearancePackages.map(pkg => Object.freeze({ + id: pkg.id, + name: pkg.name, + author: pkg.author, + description: pkg.description, + version: pkg.version, + mode: pkg.mode, + source: 'builtin' as const, + })), +); + +export function getBuiltinAppearance(id: string): AppearancePackage | null { + return builtinById.get(id) ?? null; +} + +export function getBuiltinAppearanceCssTokens( + id: string = DEFAULT_DARK_APPEARANCE_ID, +): Readonly> { + const settings = getBuiltinAppearance(id)?.renderers?.['css-tokens']?.settings; + const tokens = settings?.tokens; + if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens)) { + throw new Error(`Builtin Appearance ${id} has no css-tokens renderer settings`); + } + return tokens; +} + +export function getBuiltinAppearanceCssToken( + name: string, + id: string = DEFAULT_DARK_APPEARANCE_ID, +): string { + const value = getBuiltinAppearanceCssTokens(id)[name]; + if (!value) throw new Error(`Builtin Appearance ${id} does not define ${name}`); + return value; +} + +export function getSystemAppearanceId(): string { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return DEFAULT_LIGHT_APPEARANCE_ID; + } + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? DEFAULT_DARK_APPEARANCE_ID + : DEFAULT_LIGHT_APPEARANCE_ID; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/chinaNight.ts b/src/web-ui/src/infrastructure/appearance/builtins/chinaNight.ts new file mode 100644 index 0000000000..5e92c90f23 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/chinaNight.ts @@ -0,0 +1,210 @@ + + +import { AppearancePalette } from './AppearancePalette'; +import { + createAccentScale, + createChinaTypography, + createCompactRadius, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + createStandardEasing, + createStandardSpacing, + overlayBlack, + rgbFromHex, + rgbaFromHex, +} from './paletteHelpers'; + +const CHINA_NIGHT_BACKGROUND = '#1c1c1f'; +const CHINA_NIGHT_BACKGROUND_SECONDARY = '#212019'; +const CHINA_NIGHT_TEXT_PRIMARY = '#e8e8e8'; +const CHINA_NIGHT_BUTTON_TEXT = '#c5c3be'; +const CHINA_NIGHT_ACCENT = '#73a5cc'; +const CHINA_NIGHT_ACCENT_HOVER = '#5a8bb3'; +const CHINA_NIGHT_GREEN = '#96c6b4'; +const CHINA_NIGHT_GREEN_HOVER = '#7eb09b'; +const CHINA_NIGHT_SUCCESS = '#6bc072'; +const CHINA_NIGHT_WARNING = '#f5b555'; +const CHINA_NIGHT_ERROR = '#e85555'; + +const chinaNightText = (alpha: number | string) => rgbaFromHex(CHINA_NIGHT_TEXT_PRIMARY, alpha); +const chinaNightAccent = (alpha: number | string) => rgbaFromHex(CHINA_NIGHT_ACCENT, alpha); + +export const bitfunChinaNightPalette: AppearancePalette = { + + id: 'bitfun-china-night', + name: 'Ink Night', + type: 'dark', + description: 'Chinese dark appearance - Starlit ink night, moonlight like water, serene and elegant', + author: 'BitFun Team', + version: '1.0.0', + + + colors: { + background: { + primary: CHINA_NIGHT_BACKGROUND, + secondary: CHINA_NIGHT_BACKGROUND_SECONDARY, + tertiary: '#262626', + elevated: '#262626', + workbench: CHINA_NIGHT_BACKGROUND, + scene: CHINA_NIGHT_BACKGROUND, + }, + + text: { + primary: CHINA_NIGHT_TEXT_PRIMARY, + secondary: '#c5c3be', + muted: '#928f89', + disabled: '#555555', + }, + + accent: createAccentScale({ base: CHINA_NIGHT_ACCENT, hover: CHINA_NIGHT_ACCENT_HOVER }), + + purple: createSecondaryAccentScale({ base: CHINA_NIGHT_GREEN, hover: CHINA_NIGHT_GREEN_HOVER }), + + semantic: createSemanticColors({ + success: CHINA_NIGHT_SUCCESS, + warning: CHINA_NIGHT_WARNING, + error: CHINA_NIGHT_ERROR, + info: CHINA_NIGHT_ACCENT, + bgAlpha: 0.12, + }), + + border: { + subtle: chinaNightText(0.1), + base: chinaNightText(0.16), + medium: chinaNightText(0.22), + strong: chinaNightText(0.28), + prominent: chinaNightText(0.38), + }, + + element: { + subtle: chinaNightAccent(0.06), + soft: chinaNightAccent(0.09), + base: chinaNightAccent(0.12), + medium: chinaNightAccent(0.16), + strong: chinaNightAccent(0.2), + }, + + git: createGitColors({ + branch: rgbFromHex(CHINA_NIGHT_ACCENT), + branchBg: chinaNightAccent(0.12), + changes: rgbFromHex(CHINA_NIGHT_WARNING), + added: rgbFromHex(CHINA_NIGHT_SUCCESS), + deleted: rgbFromHex(CHINA_NIGHT_ERROR), + }), + }, + + + effects: { + shadow: { + xs: `0 1px 2px ${overlayBlack(0.5)}`, + sm: `0 2px 4px ${overlayBlack(0.6)}`, + base: `0 4px 8px ${overlayBlack(0.65)}`, + lg: `0 8px 16px ${overlayBlack(0.7)}`, + xl: `0 12px 24px ${overlayBlack(0.75)}`, + }, + + blur: { + subtle: 'blur(4px) saturate(1.1)', + base: 'blur(8px) saturate(1.15)', + }, + + radius: createCompactRadius(), + + spacing: createStandardSpacing(), + + opacity: { + disabled: 0.45, + hover: 0.75, + focus: 0.9, + }, + }, + + + motion: { + duration: { + instant: '0.1s', + fast: '0.2s', + base: '0.35s', + slow: '0.7s', + }, + + easing: createStandardEasing('cubic-bezier(0.25, 0.1, 0.25, 1)'), + }, + + + typography: createChinaTypography(), + + + components: { + button: { + + + + primary: { + default: { + background: chinaNightAccent(0.24), + color: '#88b8d8', + border: 'transparent', + shadow: 'none', + }, + hover: { + background: chinaNightAccent(0.34), + color: '#b0d5ea', + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + active: { + background: chinaNightAccent(0.28), + color: '#b0d5ea', + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + }, + + + ghost: { + default: { + color: '#9a9a9a', + }, + hover: { + background: chinaNightAccent(0.13), + color: CHINA_NIGHT_BUTTON_TEXT, + border: 'transparent', + }, + }, + }, + }, + + + monaco: { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'comment', foreground: '928f89', fontStyle: 'italic' }, + { token: 'keyword', foreground: 'e85555' }, + { token: 'string', foreground: '6bc072' }, + { token: 'number', foreground: 'f5b555' }, + { token: 'type', foreground: '73a5cc' }, + { token: 'class', foreground: '73a5cc' }, + { token: 'function', foreground: '96c6b4' }, + { token: 'variable', foreground: 'c5c3be' }, + { token: 'constant', foreground: 'd4a574' }, + { token: 'operator', foreground: 'e85555' }, + { token: 'tag', foreground: '73a5cc' }, + { token: 'attribute.name', foreground: '96c6b4' }, + { token: 'attribute.value', foreground: '6bc072' }, + ], + colors: { + background: CHINA_NIGHT_BACKGROUND, + foreground: CHINA_NIGHT_TEXT_PRIMARY, + lineHighlight: CHINA_NIGHT_BACKGROUND_SECONDARY, + selection: chinaNightAccent(0.25), + cursor: CHINA_NIGHT_ACCENT, + 'editor.selectionBackground': chinaNightAccent(0.25), + 'editorCursor.foreground': CHINA_NIGHT_ACCENT, + }, + }, +}; diff --git a/src/web-ui/src/infrastructure/appearance/builtins/chinaStyle.ts b/src/web-ui/src/infrastructure/appearance/builtins/chinaStyle.ts new file mode 100644 index 0000000000..da3544f68b --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/chinaStyle.ts @@ -0,0 +1,219 @@ + + +import { AppearancePalette } from './AppearancePalette'; +import { + createChinaTypography, + createAccentScale, + createCompactRadius, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + createStandardEasing, + createStandardSpacing, + rgbFromHex, + rgbaFromHex, + STATIC_BLACK, + STATIC_WHITE, +} from './paletteHelpers'; + +const CHINA_STYLE_PAPER = '#faf8f0'; +const CHINA_STYLE_INK = '#1c1c1f'; +const CHINA_STYLE_BUTTON_TEXT = '#3d3d3d'; +const CHINA_STYLE_BLUE = '#2e5e8a'; +const CHINA_STYLE_BLUE_HOVER = '#234a6d'; +const CHINA_STYLE_GREEN = '#7eb09b'; +const CHINA_STYLE_GREEN_HOVER = '#5a9078'; +const CHINA_STYLE_SUCCESS = '#52ad5a'; +const CHINA_STYLE_WARNING = '#f0a020'; +const CHINA_STYLE_ERROR = '#c8102e'; +const CHINA_STYLE_BORDER = '#6a5c46'; + +const chinaStyleBlue = (alpha: number | string) => rgbaFromHex(CHINA_STYLE_BLUE, alpha); +const chinaStyleBorder = (alpha: number | string) => rgbaFromHex(CHINA_STYLE_BORDER, alpha); + +export const bitfunChinaStylePalette: AppearancePalette = { + + id: 'bitfun-china-style', + name: 'Ink Charm', + type: 'light', + description: 'Chinese style appearance - Rice paper and ink, blue and vermilion, warm and elegant', + author: 'BitFun Team', + version: '1.0.0', + + + colors: { + background: { + primary: CHINA_STYLE_PAPER, + secondary: '#f5f3e8', + tertiary: '#f0ede0', + elevated: '#f0ede0', + workbench: CHINA_STYLE_PAPER, + scene: CHINA_STYLE_PAPER, + }, + + text: { + primary: CHINA_STYLE_INK, + secondary: '#3d3d3d', + muted: '#6a6a6a', + disabled: '#9a9a9a', + }, + + accent: createAccentScale({ base: CHINA_STYLE_BLUE, hover: CHINA_STYLE_BLUE_HOVER }), + + purple: createSecondaryAccentScale({ base: CHINA_STYLE_GREEN, hover: CHINA_STYLE_GREEN_HOVER }), + + semantic: createSemanticColors({ + success: CHINA_STYLE_SUCCESS, + warning: CHINA_STYLE_WARNING, + error: CHINA_STYLE_ERROR, + info: CHINA_STYLE_BLUE, + bgAlpha: 0.08, + borderAlpha: 0.25, + }), + + border: { + subtle: chinaStyleBorder(0.12), + base: chinaStyleBorder(0.2), + medium: chinaStyleBorder(0.28), + strong: chinaStyleBorder(0.36), + prominent: chinaStyleBorder(0.48), + }, + + element: { + subtle: chinaStyleBlue(0.03), + soft: chinaStyleBlue(0.06), + base: chinaStyleBlue(0.1), + medium: chinaStyleBlue(0.14), + strong: chinaStyleBlue(0.18), + }, + + git: createGitColors({ + branch: rgbFromHex(CHINA_STYLE_BLUE), + branchBg: chinaStyleBlue(0.08), + changes: rgbFromHex(CHINA_STYLE_WARNING), + added: rgbFromHex(CHINA_STYLE_SUCCESS), + deleted: rgbFromHex(CHINA_STYLE_ERROR), + }), + }, + + + effects: { + shadow: { + xs: `0 1px 2px ${chinaStyleBorder(0.06)}`, + sm: `0 2px 4px ${chinaStyleBorder(0.08)}`, + base: `0 4px 8px ${chinaStyleBorder(0.1)}`, + lg: `0 8px 16px ${chinaStyleBorder(0.12)}`, + xl: `0 12px 24px ${chinaStyleBorder(0.15)}`, + }, + + blur: { + subtle: 'blur(4px) saturate(1.03)', + base: 'blur(8px) saturate(1.05)', + }, + + radius: createCompactRadius(), + + spacing: createStandardSpacing(), + + opacity: { + disabled: 0.5, + hover: 0.75, + focus: 0.9, + }, + }, + + + motion: { + duration: { + instant: '0.1s', + fast: '0.2s', + base: '0.35s', + slow: '0.7s', + }, + + easing: createStandardEasing('cubic-bezier(0.25, 0.1, 0.25, 1)'), + }, + + + typography: createChinaTypography(), + + + components: { + button: { + + + + primary: { + default: { + background: STATIC_BLACK, + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + }, + hover: { + background: '#262626', + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + active: { + background: CHINA_STYLE_INK, + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + }, + + + ghost: { + default: { + color: '#555555', + }, + hover: { + background: chinaStyleBlue(0.11), + color: CHINA_STYLE_BUTTON_TEXT, + border: 'transparent', + }, + }, + }, + }, + + + monaco: { + base: 'vs', + inherit: true, + rules: [ + { token: 'comment', foreground: '6a6a6a', fontStyle: 'italic' }, + { token: 'keyword', foreground: 'c8102e' }, + { token: 'string', foreground: '52ad5a' }, + { token: 'number', foreground: 'f0a020' }, + { token: 'type', foreground: '2e5e8a' }, + { token: 'class', foreground: '2e5e8a' }, + { token: 'function', foreground: '7eb09b' }, + { token: 'variable', foreground: '3d3d3d' }, + { token: 'constant', foreground: 'a0522d' }, + { token: 'operator', foreground: 'c8102e' }, + { token: 'tag', foreground: '2e5e8a' }, + { token: 'attribute.name', foreground: '7eb09b' }, + { token: 'attribute.value', foreground: '52ad5a' }, + ], + colors: { + background: CHINA_STYLE_PAPER, + foreground: CHINA_STYLE_INK, + lineHighlight: '#f5f3e8', + selection: chinaStyleBlue(0.28), + cursor: CHINA_STYLE_BLUE, + + 'editor.selectionBackground': chinaStyleBlue(0.28), + 'editor.selectionForeground': CHINA_STYLE_INK, + 'editor.inactiveSelectionBackground': chinaStyleBlue(0.18), + 'editor.selectionHighlightBackground': chinaStyleBlue(0.2), + 'editor.selectionHighlightBorder': chinaStyleBlue(0.35), + 'editorCursor.foreground': CHINA_STYLE_BLUE, + 'editor.wordHighlightBackground': chinaStyleBlue(0.12), + 'editor.wordHighlightStrongBackground': chinaStyleBlue(0.22), + }, + }, +}; diff --git a/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts new file mode 100644 index 0000000000..b4754480e1 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import type { AppearancePackage } from '../types'; +import { APPEARANCE_CSS_TOKEN_NAMES } from './catalog'; +import { composeAppearancePackage } from './composeAppearancePackage'; + +describe('composeAppearancePackage', () => { + it('resolves a partial imported package into a complete host appearance', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'example.partial', + name: 'Partial', + version: '1.0.0', + mode: 'dark', + globals: { + colors: { + accent: { kind: 'hex', value: '#ff3366' }, + }, + }, + components: { + button: { + parts: { + root: { + cascade: 'override', + base: { borderRadius: { kind: 'px', value: 2 } }, + }, + }, + }, + }, + }; + + const resolved = composeAppearancePackage(pkg); + + expect(resolved.id).toBe(pkg.id); + expect(resolved.globals?.colors?.accent).toEqual({ kind: 'hex', value: '#ff3366' }); + expect(resolved.globals?.colors?.['bg-primary']).toBeDefined(); + expect(resolved.components?.button?.parts.root.base).toMatchObject({ + borderRadius: { kind: 'px', value: 2 }, + }); + expect(resolved.components?.button?.parts.root.cascade).toBe('override'); + expect(resolved.renderers?.monaco).toBeDefined(); + expect(resolved.renderers?.xterm).toBeDefined(); + expect(resolved.renderers?.mermaid).toBeDefined(); + expect(resolved.renderers?.['generative-widget']).toBeDefined(); + expect(resolved.renderers?.['bitfun-canvas']).toBeDefined(); + expect(Object.keys(resolved.renderers?.['css-tokens']?.settings.tokens ?? {})).toEqual( + expect.arrayContaining(APPEARANCE_CSS_TOKEN_NAMES), + ); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.ts b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.ts new file mode 100644 index 0000000000..48a269b34b --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.ts @@ -0,0 +1,219 @@ +import type { + AppearanceGlobalTokens, + AppearanceMaterialDefinition, + AppearancePackage, + AppearancePartRule, + AppearanceRendererDefinitions, + AppearanceStyle, + AppearanceSurfaceDefinition, +} from '../types'; +import { + DEFAULT_DARK_APPEARANCE_ID, + DEFAULT_LIGHT_APPEARANCE_ID, + getBuiltinAppearance, +} from './catalog'; + +const COMPOSITION_LAYERS = Symbol('bitfun.appearance.composition-layers'); + +interface AppearanceCompositionLayers { + base: AppearancePackage; + override: AppearancePackage; +} + +type ComposedAppearancePackage = AppearancePackage & { + [COMPOSITION_LAYERS]?: AppearanceCompositionLayers; +}; + +export function getAppearanceCompositionLayers(pkg: AppearancePackage): AppearanceCompositionLayers | undefined { + return (pkg as ComposedAppearancePackage)[COMPOSITION_LAYERS]; +} + +function mergeStyle(base: AppearanceStyle | undefined, override: AppearanceStyle | undefined): AppearanceStyle { + return { ...(base ?? {}), ...(override ?? {}) }; +} + +function mergeNestedStyles( + base: Record> | undefined, + override: Record> | undefined, +): Record> | undefined { + if (!base && !override) return undefined; + const result: Record> = {}; + new Set([...Object.keys(base ?? {}), ...Object.keys(override ?? {})]).forEach(group => { + const baseGroup = base?.[group]; + const overrideGroup = override?.[group]; + result[group] = {}; + new Set([...Object.keys(baseGroup ?? {}), ...Object.keys(overrideGroup ?? {})]).forEach(key => { + result[group][key] = mergeStyle(baseGroup?.[key], overrideGroup?.[key]); + }); + }); + return result; +} + +function mergeStateStyles( + base: Record | undefined, + override: Record | undefined, +): Record | undefined { + if (!base && !override) return undefined; + const result: Record = {}; + new Set([...Object.keys(base ?? {}), ...Object.keys(override ?? {})]).forEach(key => { + result[key] = mergeStyle(base?.[key], override?.[key]); + }); + return result; +} + +function mergePartRule(base: AppearancePartRule | undefined, override: AppearancePartRule | undefined): AppearancePartRule { + return { + ...(base?.cascade || override?.cascade ? { cascade: override?.cascade ?? base?.cascade } : {}), + ...(base?.materials || override?.materials ? { materials: override?.materials ?? base?.materials } : {}), + ...(base?.decorationIntent || override?.decorationIntent + ? { decorationIntent: override?.decorationIntent ?? base?.decorationIntent } + : {}), + ...((base?.base || override?.base) ? { base: mergeStyle(base?.base, override?.base) } : {}), + ...(mergeNestedStyles(base?.facets, override?.facets) ? { facets: mergeNestedStyles(base?.facets, override?.facets) } : {}), + ...(mergeStateStyles(base?.states, override?.states) ? { states: mergeStateStyles(base?.states, override?.states) } : {}), + ...((base?.contexts || override?.contexts) ? { contexts: [...(base?.contexts ?? []), ...(override?.contexts ?? [])] } : {}), + }; +} + +function mergeSurface( + base: AppearanceSurfaceDefinition | undefined, + override: AppearanceSurfaceDefinition | undefined, +): AppearanceSurfaceDefinition { + const parts: AppearanceSurfaceDefinition['parts'] = {}; + new Set([...Object.keys(base?.parts ?? {}), ...Object.keys(override?.parts ?? {})]).forEach(partId => { + parts[partId] = mergePartRule(base?.parts[partId], override?.parts[partId]); + }); + return { parts }; +} + +function mergeSurfaces( + base: Record | undefined, + override: Record | undefined, +): Record { + const result: Record = {}; + new Set([...Object.keys(base ?? {}), ...Object.keys(override ?? {})]).forEach(surfaceId => { + result[surfaceId] = mergeSurface(base?.[surfaceId], override?.[surfaceId]); + }); + return result; +} + +function mergeGlobals(base: AppearanceGlobalTokens | undefined, override: AppearanceGlobalTokens | undefined): AppearanceGlobalTokens { + return { + colors: { ...(base?.colors ?? {}), ...(override?.colors ?? {}) }, + lengths: { ...(base?.lengths ?? {}), ...(override?.lengths ?? {}) }, + numbers: { ...(base?.numbers ?? {}), ...(override?.numbers ?? {}) }, + durations: { ...(base?.durations ?? {}), ...(override?.durations ?? {}) }, + easings: { ...(base?.easings ?? {}), ...(override?.easings ?? {}) }, + fontFamilies: { ...(base?.fontFamilies ?? {}), ...(override?.fontFamilies ?? {}) }, + shadows: { ...(base?.shadows ?? {}), ...(override?.shadows ?? {}) }, + }; +} + +function mergeRenderers( + base: AppearanceRendererDefinitions | undefined, + override: AppearanceRendererDefinitions | undefined, +): AppearanceRendererDefinitions { + return { + 'css-tokens': override?.['css-tokens'] ? { + version: 1, + settings: { + ...base?.['css-tokens']?.settings, + ...override['css-tokens'].settings, + tokens: { + ...(base?.['css-tokens']?.settings.tokens ?? {}), + ...override['css-tokens'].settings.tokens, + }, + }, + } : base?.['css-tokens'], + monaco: override?.monaco ? { + version: 1, + settings: { + ...base?.monaco?.settings, + ...override.monaco.settings, + colors: { ...(base?.monaco?.settings.colors ?? {}), ...override.monaco.settings.colors }, + }, + } : base?.monaco, + xterm: override?.xterm ? { + version: 1, + settings: { + ...base?.xterm?.settings, + ...override.xterm.settings, + surfaces: { + terminal: { + ...(base?.xterm?.settings.surfaces.terminal ?? {}), + ...override.xterm.settings.surfaces.terminal, + }, + output: { + ...(base?.xterm?.settings.surfaces.output ?? {}), + ...override.xterm.settings.surfaces.output, + }, + }, + }, + } : base?.xterm, + mermaid: override?.mermaid ? { + version: 1, + settings: { + ...base?.mermaid?.settings, + ...override.mermaid.settings, + palette: { ...(base?.mermaid?.settings.palette ?? {}), ...override.mermaid.settings.palette }, + }, + } : base?.mermaid, + 'generative-widget': override?.['generative-widget'] ? { + version: 1, + settings: { + ...base?.['generative-widget']?.settings, + ...override['generative-widget'].settings, + vars: { + ...(base?.['generative-widget']?.settings.vars ?? {}), + ...override['generative-widget'].settings.vars, + }, + }, + } : base?.['generative-widget'], + 'bitfun-canvas': override?.['bitfun-canvas'] ? { + version: 1, + settings: { ...base?.['bitfun-canvas']?.settings, ...override['bitfun-canvas'].settings }, + } : base?.['bitfun-canvas'], + }; +} + +export function composeAppearancePackage(pkg: AppearancePackage): AppearancePackage { + const registered = getBuiltinAppearance(pkg.id); + if (registered === pkg) return pkg; + const baseId = pkg.mode === 'dark' ? DEFAULT_DARK_APPEARANCE_ID : DEFAULT_LIGHT_APPEARANCE_ID; + const base = getBuiltinAppearance(baseId); + if (!base) throw new Error(`Base appearance is unavailable: ${baseId}`); + + const requiredCapabilities = [...new Set([ + ...(base.requiredCapabilities ?? []), + ...(pkg.requiredCapabilities ?? []), + ])]; + const materials: Record = {}; + new Set([...Object.keys(base.materials ?? {}), ...Object.keys(pkg.materials ?? {})]).forEach(id => { + const baseMaterial = base.materials?.[id]; + const overrideMaterial = pkg.materials?.[id]; + materials[id] = { + style: mergeStyle(baseMaterial?.style, overrideMaterial?.style), + ...(overrideMaterial?.visualRole ?? baseMaterial?.visualRole + ? { visualRole: overrideMaterial?.visualRole ?? baseMaterial?.visualRole } + : {}), + }; + }); + + const composed: ComposedAppearancePackage = { + ...base, + ...pkg, + requiredCapabilities, + globals: mergeGlobals(base.globals, pkg.globals), + materials, + components: mergeSurfaces(base.components, pkg.components), + scenes: mergeSurfaces(base.scenes, pkg.scenes), + renderers: mergeRenderers(base.renderers, pkg.renderers), + assets: { ...(base.assets ?? {}), ...(pkg.assets ?? {}) }, + ...(pkg.integrity ? { integrity: pkg.integrity } : { integrity: undefined }), + }; + Object.defineProperty(composed, COMPOSITION_LAYERS, { + value: { base, override: pkg }, + enumerable: false, + }); + return composed; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/cyber.ts b/src/web-ui/src/infrastructure/appearance/builtins/cyber.ts new file mode 100644 index 0000000000..d15c66472c --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/cyber.ts @@ -0,0 +1,205 @@ + + +import { AppearancePalette } from './AppearancePalette'; +import { + createAccentScale, + createCompactRadius, + createExpressiveTypography, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + createStandardEasing, + createStandardSpacing, + overlayBlack, + rgbFromHex, + rgbaFromHex, + STATIC_WHITE, +} from './paletteHelpers'; + +const CYBER_BACKGROUND = '#0e0e10'; +const CYBER_TEXT_PRIMARY = '#e0f2ff'; +const CYBER_TEXT_SECONDARY = '#c7e7ff'; +const CYBER_TEXT_MUTED = '#7fadcc'; +const CYBER_ACCENT = '#00e6ff'; +const CYBER_ACCENT_HOVER = '#00ccff'; +const CYBER_PURPLE = '#8a2be2'; +const CYBER_PURPLE_HOVER = '#7928ca'; +const CYBER_SUCCESS = '#00ff9f'; +const CYBER_WARNING = '#ffcc00'; +const CYBER_ERROR = '#ff0055'; +const CYBER_SURFACE_SECONDARY = '#1c1c1f'; + +const cyberAccent = (alpha: number | string) => rgbaFromHex(CYBER_ACCENT, alpha); + +export const bitfunCyberPalette: AppearancePalette = { + + id: 'bitfun-cyber', + name: 'Cyber', + type: 'dark', + description: 'Tech-style appearance - Deep black hole, neon future, ultimate tech aesthetics', + author: 'BitFun Team', + version: '1.0.0', + + + colors: { + background: { + primary: CYBER_BACKGROUND, + secondary: CYBER_SURFACE_SECONDARY, + tertiary: CYBER_SURFACE_SECONDARY, + elevated: CYBER_BACKGROUND, + workbench: CYBER_BACKGROUND, + scene: CYBER_SURFACE_SECONDARY, + }, + + text: { + primary: CYBER_TEXT_PRIMARY, + secondary: CYBER_TEXT_SECONDARY, + muted: CYBER_TEXT_MUTED, + disabled: '#475569', + }, + + accent: createAccentScale({ + base: CYBER_ACCENT, + hover: CYBER_ACCENT_HOVER, + alpha: { 50: 0.05, 100: 0.1, 200: 0.18, 300: 0.3, 400: 0.45, 700: 0.85 }, + }), + + purple: createSecondaryAccentScale({ + base: CYBER_PURPLE, + hover: CYBER_PURPLE_HOVER, + alpha: { 100: 0.1, 200: 0.18 }, + }), + + semantic: createSemanticColors({ + success: CYBER_SUCCESS, + warning: CYBER_WARNING, + error: CYBER_ERROR, + info: CYBER_ACCENT, + bgAlpha: 0.12, + borderAlpha: 0.35, + }), + + border: { + subtle: cyberAccent(0.14), + base: cyberAccent(0.2), + medium: cyberAccent(0.28), + strong: cyberAccent(0.36), + prominent: cyberAccent(0.5), + }, + + element: { + subtle: cyberAccent(0.06), + soft: cyberAccent(0.09), + base: cyberAccent(0.13), + medium: cyberAccent(0.17), + strong: cyberAccent(0.22), + }, + + git: createGitColors({ + branch: rgbFromHex(CYBER_ACCENT), + branchBg: cyberAccent(0.12), + changes: rgbFromHex(CYBER_WARNING), + added: rgbFromHex(CYBER_SUCCESS), + deleted: rgbFromHex(CYBER_ERROR), + }), + }, + + + effects: { + shadow: { + xs: '0 1px 3px rgba(0, 0, 0, 0.9)', + sm: '0 2px 6px rgba(0, 0, 0, 0.85)', + base: '0 4px 12px rgba(0, 0, 0, 0.8)', + lg: '0 8px 20px rgba(0, 0, 0, 0.75)', + xl: '0 12px 28px rgba(0, 0, 0, 0.7)', + }, + + blur: { + subtle: 'blur(4px) saturate(1.2)', + base: 'blur(8px) saturate(1.3)', + }, + + radius: createCompactRadius(), + + spacing: createStandardSpacing(), + + opacity: { + disabled: 0.5, + hover: 0.85, + focus: 0.95, + }, + }, + + + motion: { + duration: { + instant: '0.08s', + fast: '0.12s', + base: '0.25s', + slow: '0.5s', + }, + + easing: createStandardEasing('cubic-bezier(0.25, 0.46, 0.45, 0.94)'), + }, + + + typography: createExpressiveTypography(), + + + components: { + button: { + + + + primary: { + default: { + background: cyberAccent(0.18), + color: CYBER_TEXT_PRIMARY, + border: cyberAccent(0.4), + shadow: `0 0 16px ${cyberAccent(0.25)}`, + }, + hover: { + background: cyberAccent(0.25), + color: STATIC_WHITE, + border: cyberAccent(0.6), + shadow: `0 0 24px ${cyberAccent(0.4)}, 0 0 36px ${cyberAccent(0.2)}, 0 4px 12px ${overlayBlack(0.3)}`, + transform: 'translateY(-2px)', + }, + active: { + background: cyberAccent(0.22), + color: STATIC_WHITE, + border: cyberAccent(0.5), + shadow: `0 0 20px ${cyberAccent(0.3)}`, + transform: 'translateY(-1px)', + }, + }, + + + ghost: { + default: { + color: CYBER_TEXT_MUTED, + }, + hover: { + background: cyberAccent(0.1), + color: CYBER_TEXT_SECONDARY, + border: cyberAccent(0.35), + }, + }, + }, + }, + + + monaco: { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + background: CYBER_BACKGROUND, + foreground: CYBER_TEXT_SECONDARY, + lineHighlight: CYBER_SURFACE_SECONDARY, + selection: '#1a4d66', + cursor: CYBER_ACCENT, + }, + }, +}; + diff --git a/src/web-ui/src/infrastructure/appearance/builtins/dark.ts b/src/web-ui/src/infrastructure/appearance/builtins/dark.ts new file mode 100644 index 0000000000..2275fbed0b --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/dark.ts @@ -0,0 +1,195 @@ + + +import { AppearancePalette } from './AppearancePalette'; +import { + createAccentScale, + createDarkNeutralBorder, + createDarkNeutralElement, + createDarkNeutralScrollbar, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + createStandardEasing, + createStandardRadius, + createStandardSpacing, + createStandardTypography, + overlayBlack, + overlayWhite, + rgbFromHex, + STATIC_WHITE, +} from './paletteHelpers'; + +const DARK_BACKGROUND_PRIMARY = '#0e0e10'; +const DARK_BACKGROUND_SECONDARY = '#1c1c1f'; +const DARK_TEXT_PRIMARY = '#e8e8e8'; +const DARK_BUTTON_TEXT = '#c8c8c8'; +const DARK_ACCENT = '#60a5fa'; +const DARK_ACCENT_HOVER = '#3b82f6'; +const DARK_PURPLE = '#8b5cf6'; +const DARK_PURPLE_HOVER = '#7c3aed'; +const DARK_SUCCESS = '#34d399'; +const DARK_WARNING = '#f59e0b'; +const DARK_ERROR = '#ef4444'; + +export const bitfunDarkPalette: AppearancePalette = { + + id: 'bitfun-dark', + name: 'Dark', + type: 'dark', + description: 'Default dark appearance', + author: 'BitFun Team', + version: '2.1.0', + + + colors: { + background: { + primary: DARK_BACKGROUND_PRIMARY, + secondary: DARK_BACKGROUND_SECONDARY, + tertiary: DARK_BACKGROUND_PRIMARY, + elevated: DARK_BACKGROUND_SECONDARY, + workbench: DARK_BACKGROUND_PRIMARY, + scene: DARK_BACKGROUND_SECONDARY, + }, + + text: { + primary: DARK_TEXT_PRIMARY, + secondary: '#b0b0b0', + muted: '#858585', + disabled: '#555555', + }, + + accent: createAccentScale({ base: DARK_ACCENT, hover: DARK_ACCENT_HOVER }), + + purple: createSecondaryAccentScale({ base: DARK_PURPLE, hover: DARK_PURPLE_HOVER }), + + semantic: createSemanticColors({ + success: DARK_SUCCESS, + warning: DARK_WARNING, + error: DARK_ERROR, + info: '#a1a1aa', + overrides: { + infoBg: overlayWhite(0.08), + infoBorder: overlayWhite(0.24), + }, + }), + + border: createDarkNeutralBorder(), + + element: createDarkNeutralElement(), + + git: createGitColors({ + branch: '#a1a1aa', + branchBg: overlayWhite(0.06), + changes: rgbFromHex(DARK_WARNING), + added: 'rgb(34, 197, 94)', + deleted: rgbFromHex(DARK_ERROR), + }), + + scrollbar: createDarkNeutralScrollbar(), + }, + + + effects: { + shadow: { + xs: `0 1px 2px ${overlayBlack(0.9)}`, + sm: `0 2px 4px ${overlayBlack(0.8)}`, + base: `0 4px 8px ${overlayBlack(0.7)}`, + lg: `0 8px 16px ${overlayBlack(0.6)}`, + xl: `0 12px 24px ${overlayBlack(0.5)}`, + }, + + blur: { + subtle: 'blur(4px) saturate(1.05)', + base: 'blur(8px) saturate(1.1)', + }, + + radius: createStandardRadius(), + + spacing: createStandardSpacing(), + + opacity: { + disabled: 0.6, + hover: 0.8, + focus: 0.9, + }, + }, + + + motion: { + duration: { + instant: '0.1s', + fast: '0.15s', + base: '0.3s', + slow: '0.6s', + }, + + easing: createStandardEasing(), + }, + + + typography: createStandardTypography(), + + + components: { + button: { + + + + primary: { + default: { + background: overlayWhite(0.16), + color: '#f3f3f5', + border: 'transparent', + shadow: 'none', + }, + hover: { + background: overlayWhite(0.24), + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + active: { + background: overlayWhite(0.2), + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + }, + + + ghost: { + default: { + color: '#9a9a9a', + }, + hover: { + background: overlayWhite(0.1), + color: DARK_BUTTON_TEXT, + border: 'transparent', + }, + }, + }, + }, + + + + + monaco: { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + background: DARK_BACKGROUND_PRIMARY, + foreground: DARK_TEXT_PRIMARY, + lineHighlight: DARK_BACKGROUND_SECONDARY, + selection: overlayWhite(0.12), + cursor: DARK_BUTTON_TEXT, + }, + }, +}; + + + + + diff --git a/src/web-ui/src/infrastructure/appearance/builtins/light.ts b/src/web-ui/src/infrastructure/appearance/builtins/light.ts new file mode 100644 index 0000000000..7ecb7f9571 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/light.ts @@ -0,0 +1,258 @@ + + +import { AppearancePalette } from './AppearancePalette'; +import { + createAccentScale, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + createStandardEasing, + createStandardRadius, + createStandardSpacing, + createStandardTypography, + rgbFromHex, + rgbaFromHex, + STATIC_BLACK, + STATIC_WHITE, +} from './paletteHelpers'; + +const LIGHT_INK = '#0f172a'; +const LIGHT_TEXT_PRIMARY = '#1e293b'; +const LIGHT_TEXT_STRONG = '#334155'; +const LIGHT_ACCENT = '#64748b'; +const LIGHT_ACCENT_HOVER = '#475569'; +const LIGHT_PURPLE = '#7c6b99'; +const LIGHT_PURPLE_HOVER = '#655680'; +const LIGHT_SUCCESS = '#5b9a6f'; +const LIGHT_WARNING = '#c08c42'; +const LIGHT_ERROR = '#c26565'; + +const lightInk = (alpha: number | string) => rgbaFromHex(LIGHT_INK, alpha); +const lightAccent = (alpha: number | string) => rgbaFromHex(LIGHT_ACCENT, alpha); +const lightAccentHover = (alpha: number | string) => rgbaFromHex(LIGHT_ACCENT_HOVER, alpha); + +export const bitfunLightPalette: AppearancePalette = { + + id: 'bitfun-light', + name: 'Light', + type: 'light', + description: 'Light appearance - Neutral gray surfaces, black primary actions', + author: 'BitFun Team', + version: '2.3.0', + + layout: { + sceneViewportBorder: false, + }, + + + colors: { + background: { + primary: '#f3f3f5', + secondary: STATIC_WHITE, + tertiary: '#e8e8e8', + elevated: STATIC_WHITE, + workbench: '#e8e8e8', + scene: STATIC_WHITE, + }, + + text: { + primary: LIGHT_TEXT_PRIMARY, + secondary: '#3d4f66', + muted: LIGHT_ACCENT, + disabled: '#94a3b8', + }, + + + accent: createAccentScale({ + base: LIGHT_ACCENT, + hover: LIGHT_ACCENT_HOVER, + alpha: { 700: 0.88 }, + stops: { + 50: lightInk(0.04), + 100: lightInk(0.07), + 200: lightInk(0.1), + 300: lightInk(0.16), + 400: lightInk(0.26), + }, + }), + + + purple: createSecondaryAccentScale({ + base: '#6b5a89', + hover: LIGHT_PURPLE_HOVER, + alpha: { 200: 0.14 }, + stops: { + 500: LIGHT_PURPLE, + }, + }), + + + semantic: createSemanticColors({ + success: LIGHT_SUCCESS, + warning: LIGHT_WARNING, + error: LIGHT_ERROR, + info: LIGHT_ACCENT, + bgAlpha: 0.08, + borderAlpha: 0.25, + overrides: { + infoBg: lightAccent(0.1), + infoBorder: lightAccent(0.28), + }, + }), + + + border: { + subtle: lightAccent(0.15), + base: lightAccent(0.22), + medium: lightAccent(0.32), + strong: lightAccent(0.42), + prominent: lightAccent(0.52), + }, + + + element: { + subtle: lightInk(0.045), + soft: lightInk(0.065), + base: lightInk(0.09), + medium: lightInk(0.12), + strong: lightInk(0.16), + }, + + + git: createGitColors({ + branch: rgbFromHex(LIGHT_ACCENT_HOVER), + branchBg: lightAccentHover(0.1), + changes: rgbFromHex(LIGHT_WARNING), + added: rgbFromHex(LIGHT_SUCCESS), + deleted: rgbFromHex(LIGHT_ERROR), + }), + }, + + + effects: { + shadow: { + + xs: `0 1px 2px ${lightAccentHover(0.06)}`, + sm: `0 2px 4px ${lightAccentHover(0.08)}`, + base: `0 4px 8px ${lightAccentHover(0.1)}`, + lg: `0 8px 16px ${lightAccentHover(0.12)}`, + xl: `0 12px 24px ${lightAccentHover(0.14)}`, + }, + + + blur: { + subtle: 'blur(4px) saturate(1.02)', + base: 'blur(8px) saturate(1.05)', + }, + + radius: createStandardRadius(), + + spacing: createStandardSpacing(), + + opacity: { + disabled: 0.55, + hover: 0.75, + focus: 0.9, + }, + }, + + + motion: { + duration: { + instant: '0.1s', + fast: '0.15s', + base: '0.3s', + slow: '0.6s', + }, + + easing: createStandardEasing(), + }, + + + typography: createStandardTypography(), + + + components: { + button: { + + + + primary: { + default: { + background: STATIC_BLACK, + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + }, + hover: { + background: '#262626', + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + active: { + background: '#1c1c1f', + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + }, + + + ghost: { + default: { + color: LIGHT_ACCENT_HOVER, + }, + hover: { + background: lightInk(0.08), + color: LIGHT_TEXT_STRONG, + border: 'transparent', + }, + }, + }, + }, + + + monaco: { + base: 'vs', + inherit: true, + rules: [ + { token: 'comment', foreground: '94a3b8', fontStyle: 'italic' }, + { token: 'keyword', foreground: '6b5a89' }, + { token: 'string', foreground: '5b9a6f' }, + { token: 'number', foreground: 'b8863a' }, + { token: 'type', foreground: '475569' }, + { token: 'class', foreground: '475569' }, + { token: 'function', foreground: '7c6b99' }, + { token: 'variable', foreground: '475569' }, + { token: 'constant', foreground: 'c08c42' }, + { token: 'operator', foreground: '6b5a89' }, + { token: 'tag', foreground: '475569' }, + { token: 'attribute.name', foreground: '7c6b99' }, + { token: 'attribute.value', foreground: '5b9a6f' }, + ], + colors: { + background: '#f3f3f5', + foreground: LIGHT_TEXT_PRIMARY, + lineHighlight: '#f0f4f8', + selection: lightInk(0.14), + cursor: LIGHT_TEXT_PRIMARY, + + 'editor.selectionBackground': lightInk(0.14), + 'editor.selectionForeground': LIGHT_TEXT_PRIMARY, + 'editor.inactiveSelectionBackground': lightInk(0.09), + 'editor.selectionHighlightBackground': lightInk(0.1), + 'editor.selectionHighlightBorder': lightInk(0.22), + 'editorCursor.foreground': LIGHT_TEXT_PRIMARY, + + 'editor.wordHighlightBackground': lightInk(0.07), + 'editor.wordHighlightStrongBackground': lightInk(0.11), + }, + }, +}; + + + + diff --git a/src/web-ui/src/infrastructure/appearance/builtins/midnight.ts b/src/web-ui/src/infrastructure/appearance/builtins/midnight.ts new file mode 100644 index 0000000000..6d2947fef2 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/midnight.ts @@ -0,0 +1,208 @@ + + +import { AppearancePalette } from './AppearancePalette'; +import { + createAccentScale, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + createStandardEasing, + createStandardRadius, + createStandardSpacing, + createStandardTypography, + overlayBlack, + overlayWhite, + rgbFromHex, + rgbaFromHex, +} from './paletteHelpers'; + +const MIDNIGHT_BACKGROUND = '#2b2d30'; +const MIDNIGHT_BACKGROUND_SECONDARY = '#1c1c1f'; +const MIDNIGHT_TEXT_PRIMARY = '#c8c8c8'; +const MIDNIGHT_BUTTON_TEXT = '#b0b0b0'; +const MIDNIGHT_ACCENT = '#60a5fa'; +const MIDNIGHT_ACCENT_HOVER = '#3b82f6'; +const MIDNIGHT_PURPLE = '#9c78ff'; +const MIDNIGHT_PURPLE_HOVER = '#8b5cf6'; +const MIDNIGHT_SUCCESS = '#6aab73'; +const MIDNIGHT_WARNING = '#e0a055'; +const MIDNIGHT_ERROR = '#cc7f7a'; + +const midnightText = (alpha: number | string) => rgbaFromHex(MIDNIGHT_TEXT_PRIMARY, alpha); +const midnightAccent = (alpha: number | string) => rgbaFromHex(MIDNIGHT_ACCENT, alpha); + +export const bitfunMidnightPalette: AppearancePalette = { + + id: 'bitfun-midnight', + name: 'Midnight', + type: 'dark', + description: 'Midnight gray dark appearance - Professional and elegant, inspired by JetBrains IDE', + author: 'BitFun Team', + version: '1.0.0', + + + colors: { + background: { + primary: MIDNIGHT_BACKGROUND, + secondary: MIDNIGHT_BACKGROUND_SECONDARY, + tertiary: '#313335', + elevated: MIDNIGHT_BACKGROUND, + workbench: MIDNIGHT_BACKGROUND_SECONDARY, + scene: MIDNIGHT_BACKGROUND, + }, + + text: { + primary: MIDNIGHT_TEXT_PRIMARY, + secondary: '#a1a1aa', + muted: '#6f737a', + disabled: '#555555', + }, + + accent: createAccentScale({ base: MIDNIGHT_ACCENT, hover: MIDNIGHT_ACCENT_HOVER }), + + purple: createSecondaryAccentScale({ base: MIDNIGHT_PURPLE, hover: MIDNIGHT_PURPLE_HOVER }), + + semantic: createSemanticColors({ + success: MIDNIGHT_SUCCESS, + warning: MIDNIGHT_WARNING, + error: MIDNIGHT_ERROR, + info: MIDNIGHT_ACCENT, + }), + + border: { + subtle: overlayWhite(0.08), + base: overlayWhite(0.14), + medium: overlayWhite(0.2), + strong: overlayWhite(0.26), + prominent: overlayWhite(0.35), + }, + + element: { + subtle: overlayWhite(0.04), + soft: overlayWhite(0.06), + base: overlayWhite(0.09), + medium: overlayWhite(0.12), + strong: overlayWhite(0.15), + }, + + git: createGitColors({ + branch: rgbFromHex(MIDNIGHT_ACCENT), + branchBg: midnightAccent(0.1), + changes: rgbFromHex(MIDNIGHT_WARNING), + added: rgbFromHex(MIDNIGHT_SUCCESS), + deleted: rgbFromHex(MIDNIGHT_ERROR), + }), + }, + + + effects: { + shadow: { + xs: `0 1px 2px ${overlayBlack(0.8)}`, + sm: `0 2px 4px ${overlayBlack(0.75)}`, + base: `0 4px 8px ${overlayBlack(0.7)}`, + lg: `0 8px 16px ${overlayBlack(0.65)}`, + xl: `0 12px 24px ${overlayBlack(0.8)}`, + }, + + blur: { + subtle: 'blur(4px) saturate(1.1)', + base: 'blur(8px) saturate(1.2)', + }, + + radius: createStandardRadius(), + + spacing: createStandardSpacing(), + + opacity: { + disabled: 0.5, + hover: 0.8, + focus: 0.9, + }, + }, + + + motion: { + duration: { + instant: '0.1s', + fast: '0.15s', + base: '0.3s', + slow: '0.6s', + }, + + easing: createStandardEasing(), + }, + + + typography: createStandardTypography(), + + + components: { + button: { + + + + primary: { + default: { + background: midnightAccent(0.2), + color: '#6aa8e8', + border: 'transparent', + shadow: 'none', + }, + hover: { + background: midnightAccent(0.3), + color: '#8fc0f0', + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + active: { + background: midnightAccent(0.24), + color: '#8fc0f0', + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + }, + + + ghost: { + default: { + color: '#9a9a9a', + }, + hover: { + background: midnightText(0.13), + color: MIDNIGHT_BUTTON_TEXT, + border: 'transparent', + }, + }, + }, + }, + + + monaco: { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'comment', foreground: '6f737a', fontStyle: 'italic' }, + { token: 'keyword', foreground: 'cc7832' }, + { token: 'string', foreground: '6aab73' }, + { token: 'number', foreground: '6897bb' }, + { token: 'type', foreground: 'e0a055' }, + { token: 'class', foreground: 'e0a055' }, + { token: 'function', foreground: 'ffc66d' }, + { token: 'variable', foreground: 'bcbec4' }, + { token: 'constant', foreground: '9876aa' }, + { token: 'operator', foreground: 'cc7832' }, + { token: 'tag', foreground: 'e8bf6a' }, + { token: 'attribute.name', foreground: 'bababa' }, + { token: 'attribute.value', foreground: 'a5c261' }, + ], + colors: { + background: MIDNIGHT_BACKGROUND, + foreground: MIDNIGHT_TEXT_PRIMARY, + lineHighlight: '#313335', + selection: '#3d4752', + cursor: MIDNIGHT_ACCENT, + }, + }, +}; diff --git a/src/web-ui/src/infrastructure/appearance/builtins/paletteHelpers.ts b/src/web-ui/src/infrastructure/appearance/builtins/paletteHelpers.ts new file mode 100644 index 0000000000..b7d238c481 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/paletteHelpers.ts @@ -0,0 +1,296 @@ +import type { + AccentColors, + BorderColors, + ElementBackgrounds, + GitColors, + RadiusConfig, + ScrollbarColors, + SecondaryAccentColors, + SemanticColors, + AppearancePalette, +} from './AppearancePalette'; + +export const STATIC_BLACK = '#000000'; +export const STATIC_WHITE = '#ffffff'; + +function hexToRgbChannels(hex: string): [number, number, number] { + const raw = hex.trim().replace(/^#/, ''); + const expanded = raw.length === 3 + ? raw.split('').map(channel => channel + channel).join('') + : raw; + if (!/^[0-9a-f]{6}$/i.test(expanded)) { + throw new Error(`Invalid hex color: ${hex}`); + } + const value = Number.parseInt(expanded, 16); + return [ + (value >> 16) & 255, + (value >> 8) & 255, + value & 255, + ]; +} + +export function rgbFromHex(hex: string): string { + const [r, g, b] = hexToRgbChannels(hex); + return `rgb(${r}, ${g}, ${b})`; +} + +export function rgbaFromHex(hex: string, alpha: number | string): string { + const [r, g, b] = hexToRgbChannels(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export function overlayBlack(alpha: number | string): string { + return rgbaFromHex(STATIC_BLACK, alpha); +} + +export function overlayWhite(alpha: number | string): string { + return rgbaFromHex(STATIC_WHITE, alpha); +} + +export interface AccentScaleInput { + base: string; + hover?: string; + stops?: Partial; + alpha?: Partial>; +} + +export interface SecondaryAccentScaleInput { + base: string; + hover?: string; + stops?: Partial; + alpha?: Partial>; +} + +export interface SemanticColorsInput { + success: string; + warning: string; + error: string; + info: string; + bgAlpha?: number | string; + borderAlpha?: number | string; + overrides?: Partial; +} + +export function createAccentScale(input: AccentScaleInput): AccentColors { + const hover = input.hover ?? input.base; + const alpha = { + 50: 0.04, + 100: 0.08, + 200: 0.15, + 300: 0.25, + 400: 0.4, + 700: 0.8, + ...input.alpha, + }; + + return { + 50: rgbaFromHex(input.base, alpha[50]), + 100: rgbaFromHex(input.base, alpha[100]), + 200: rgbaFromHex(input.base, alpha[200]), + 300: rgbaFromHex(input.base, alpha[300]), + 400: rgbaFromHex(input.base, alpha[400]), + 500: input.base, + 600: hover, + 700: rgbaFromHex(hover, alpha[700]), + ...input.stops, + }; +} + +export function createSecondaryAccentScale(input: SecondaryAccentScaleInput): SecondaryAccentColors { + const hover = input.hover ?? input.base; + const alpha = { + 100: 0.08, + 200: 0.15, + ...input.alpha, + }; + + return { + 100: rgbaFromHex(input.base, alpha[100]), + 200: rgbaFromHex(input.base, alpha[200]), + 500: input.base, + 600: hover, + ...input.stops, + }; +} + +export function createSemanticColors(input: SemanticColorsInput): SemanticColors { + const bgAlpha = input.bgAlpha ?? 0.1; + const borderAlpha = input.borderAlpha ?? 0.3; + + return { + success: input.success, + successBg: rgbaFromHex(input.success, bgAlpha), + successBorder: rgbaFromHex(input.success, borderAlpha), + warning: input.warning, + warningBg: rgbaFromHex(input.warning, bgAlpha), + warningBorder: rgbaFromHex(input.warning, borderAlpha), + error: input.error, + errorBg: rgbaFromHex(input.error, bgAlpha), + errorBorder: rgbaFromHex(input.error, borderAlpha), + info: input.info, + infoBg: rgbaFromHex(input.info, bgAlpha), + infoBorder: rgbaFromHex(input.info, borderAlpha), + ...input.overrides, + }; +} + +export function createStandardTypography(): AppearancePalette['typography'] { + return { + font: { + sans: "'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif", + mono: "'JetBrains Mono', 'FiraCode', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace", + }, + weight: { + normal: 400, + medium: 500, + semibold: 600, + }, + size: { + xs: '12px', + sm: '13px', + base: '14px', + lg: '15px', + xl: '16px', + '2xl': '18px', + '3xl': '22px', + '4xl': '26px', + }, + lineHeight: { + tight: 1.2, + base: 1.5, + relaxed: 1.6, + }, + }; +} + +export function createExpressiveTypography(): AppearancePalette['typography'] { + return { + ...createStandardTypography(), + lineHeight: { + tight: 1.3, + base: 1.5, + relaxed: 1.65, + }, + }; +} + +export function createChinaTypography(): AppearancePalette['typography'] { + return { + font: { + sans: "'Noto Sans SC', 'Source Han Sans CN', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif", + mono: "'Source Han Mono CN', 'Noto Sans Mono CJK SC', 'JetBrains Mono', 'FiraCode', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace", + }, + weight: { + normal: 400, + medium: 500, + semibold: 600, + }, + size: { + xs: '12px', + sm: '13px', + base: '14px', + lg: '15px', + xl: '16px', + '2xl': '18px', + '3xl': '22px', + '4xl': '26px', + }, + lineHeight: { + tight: 1.3, + base: 1.6, + relaxed: 1.8, + }, + }; +} + +export function createStandardSpacing(): AppearancePalette['effects']['spacing'] { + return { + 1: '4px', + 2: '8px', + 3: '12px', + 4: '16px', + 5: '20px', + 6: '24px', + 8: '32px', + 10: '40px', + 12: '48px', + 16: '64px', + }; +} + +export function createStandardRadius(): RadiusConfig { + return { + sm: '6px', + base: '8px', + lg: '12px', + xl: '16px', + '2xl': '20px', + full: '9999px', + }; +} + +export function createCompactRadius(): RadiusConfig { + return { + sm: '4px', + base: '6px', + lg: '10px', + xl: '14px', + '2xl': '18px', + full: '9999px', + }; +} + +export function createSlateRadius(): RadiusConfig { + return { + sm: '4px', + base: '6px', + lg: '8px', + xl: '12px', + '2xl': '16px', + full: '9999px', + }; +} + +export function createStandardEasing(smooth = 'cubic-bezier(0.4, 0, 0.2, 1)'): AppearancePalette['motion']['easing'] { + return { + standard: 'cubic-bezier(0.4, 0, 0.2, 1)', + decelerate: 'cubic-bezier(0, 0, 0.2, 1)', + smooth, + }; +} + +export function createDarkNeutralBorder(): BorderColors { + return { + subtle: overlayWhite(0.12), + base: overlayWhite(0.18), + medium: overlayWhite(0.24), + strong: overlayWhite(0.3), + prominent: overlayWhite(0.4), + }; +} + +export function createDarkNeutralElement(): ElementBackgrounds { + return { + subtle: overlayWhite(0.06), + soft: overlayWhite(0.06), + base: overlayWhite(0.1), + medium: overlayWhite(0.12), + strong: overlayWhite(0.15), + }; +} + +export function createGitColors( + config: Omit & Partial>, +): GitColors { + return { + ...config, + staged: config.staged ?? config.added, + }; +} + +export function createDarkNeutralScrollbar(): ScrollbarColors { + return { + thumb: overlayWhite(0.15), + thumbHover: overlayWhite(0.3), + }; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/palettes.ts b/src/web-ui/src/infrastructure/appearance/builtins/palettes.ts new file mode 100644 index 0000000000..33e2ef8862 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/palettes.ts @@ -0,0 +1,32 @@ +export { bitfunDarkPalette } from './dark'; +export { bitfunLightPalette } from './light'; +export { bitfunMidnightPalette } from './midnight'; +export { bitfunChinaStylePalette } from './chinaStyle'; +export { bitfunChinaNightPalette } from './chinaNight'; +export { bitfunCyberPalette } from './cyber'; +export { bitfunSlatePalette } from './slate'; +export { bitfunTokyoNightPalette } from './tokyoNight'; + +import { bitfunDarkPalette } from './dark'; +import { bitfunLightPalette } from './light'; +import { bitfunMidnightPalette } from './midnight'; +import { bitfunChinaStylePalette } from './chinaStyle'; +import { bitfunChinaNightPalette } from './chinaNight'; +import { bitfunCyberPalette } from './cyber'; +import { bitfunSlatePalette } from './slate'; +import { bitfunTokyoNightPalette } from './tokyoNight'; +import type { AppearancePalette, AppearancePaletteId } from './AppearancePalette'; + +export const DEFAULT_LIGHT_APPEARANCE_ID: AppearancePaletteId = 'bitfun-light'; +export const DEFAULT_DARK_APPEARANCE_ID: AppearancePaletteId = 'bitfun-dark'; + +export const builtinAppearancePalettes: readonly AppearancePalette[] = Object.freeze([ + bitfunLightPalette, + bitfunSlatePalette, + bitfunDarkPalette, + bitfunMidnightPalette, + bitfunChinaStylePalette, + bitfunChinaNightPalette, + bitfunCyberPalette, + bitfunTokyoNightPalette, +]); diff --git a/src/web-ui/src/infrastructure/appearance/builtins/slate.ts b/src/web-ui/src/infrastructure/appearance/builtins/slate.ts new file mode 100644 index 0000000000..46b531be6f --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/slate.ts @@ -0,0 +1,222 @@ + + +import { AppearancePalette } from './AppearancePalette'; +import { + createAccentScale, + createDarkNeutralBorder, + createDarkNeutralElement, + createDarkNeutralScrollbar, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + createSlateRadius, + createStandardEasing, + createStandardSpacing, + createStandardTypography, + overlayBlack, + overlayWhite, + rgbFromHex, + rgbaFromHex, + STATIC_WHITE, +} from './paletteHelpers'; + +const SLATE_BACKGROUND_PRIMARY = '#1c1c1f'; +const SLATE_BACKGROUND_SECONDARY = '#262626'; +const SLATE_TEXT_PRIMARY = '#e8e8e8'; +const SLATE_TEXT_MUTED = '#a8b0bd'; +const SLATE_BUTTON_TEXT = SLATE_TEXT_PRIMARY; +const SLATE_ACCENT_SOFT = '#e8e8e8'; +const SLATE_ACCENT = '#94a3b8'; +const SLATE_ACCENT_HOVER = '#64748b'; +const SLATE_PURPLE = '#b8c4ff'; +const SLATE_PURPLE_HOVER = '#9dacf5'; +const SLATE_SUCCESS = '#7eb09b'; +const SLATE_WARNING = '#f59e0b'; +const SLATE_ERROR = '#c9878d'; + +export const bitfunSlatePalette: AppearancePalette = { + + id: 'bitfun-slate', + name: 'Slate', + type: 'dark', + description: 'Slate gray geometric appearance - Deep immersion, high contrast grayscale aesthetics', + author: 'BitFun Team', + version: '1.3.0', + + layout: { + sceneViewportBorder: false, + }, + + colors: { + background: { + primary: SLATE_BACKGROUND_PRIMARY, + secondary: SLATE_BACKGROUND_SECONDARY, + tertiary: SLATE_BACKGROUND_PRIMARY, + elevated: SLATE_BACKGROUND_SECONDARY, + workbench: SLATE_BACKGROUND_PRIMARY, + scene: SLATE_BACKGROUND_SECONDARY, + }, + + text: { + primary: SLATE_TEXT_PRIMARY, + secondary: '#c8ccd2', + muted: '#a1a1aa', + disabled: '#6a6a6a', + }, + + + // Cool gray accent — neutral chrome for slate surfaces (links, focus, nav tints). + accent: createAccentScale({ + base: SLATE_ACCENT, + hover: SLATE_ACCENT_HOVER, + alpha: { 700: 0.85 }, + stops: { + 50: rgbaFromHex(SLATE_ACCENT_SOFT, 0.05), + 100: rgbaFromHex(SLATE_ACCENT_SOFT, 0.09), + 200: 'rgba(203, 213, 225, 0.14)', + 300: 'rgba(203, 213, 225, 0.24)', + 400: 'rgba(148, 163, 184, 0.45)', + }, + }), + + + purple: createSecondaryAccentScale({ base: SLATE_PURPLE, hover: SLATE_PURPLE_HOVER }), + + semantic: createSemanticColors({ + success: SLATE_SUCCESS, + warning: SLATE_WARNING, + error: SLATE_ERROR, + info: SLATE_TEXT_MUTED, + overrides: { + infoBg: overlayWhite(0.07), + infoBorder: overlayWhite(0.2), + }, + }), + + border: createDarkNeutralBorder(), + + element: createDarkNeutralElement(), + + git: createGitColors({ + branch: SLATE_ACCENT, + branchBg: overlayWhite(0.06), + changes: rgbFromHex(SLATE_WARNING), + added: rgbFromHex(SLATE_SUCCESS), + deleted: rgbFromHex(SLATE_ERROR), + }), + + scrollbar: createDarkNeutralScrollbar(), + }, + + + effects: { + shadow: { + xs: `0 1px 2px ${overlayBlack(0.85)}`, + sm: `0 2px 4px ${overlayBlack(0.8)}`, + base: `0 4px 8px ${overlayBlack(0.75)}`, + lg: `0 8px 16px ${overlayBlack(0.7)}`, + xl: `0 12px 24px ${overlayBlack(0.85)}`, + }, + + blur: { + subtle: 'blur(4px) saturate(1.05) brightness(0.98)', + base: 'blur(8px) saturate(1.08) brightness(0.98)', + }, + + radius: createSlateRadius(), + + spacing: createStandardSpacing(), + + opacity: { + disabled: 0.5, + hover: 0.75, + focus: 0.85, + }, + }, + + + motion: { + duration: { + instant: '0.08s', + fast: '0.12s', + base: '0.25s', + slow: '0.5s', + }, + + easing: createStandardEasing(), + }, + + + typography: createStandardTypography(), + + + components: { + button: { + + + + primary: { + default: { + background: overlayWhite(0.14), + color: SLATE_TEXT_PRIMARY, + border: 'transparent', + shadow: 'none', + }, + hover: { + background: overlayWhite(0.2), + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + active: { + background: overlayWhite(0.17), + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + }, + + + ghost: { + default: { + color: SLATE_TEXT_MUTED, + }, + hover: { + background: overlayWhite(0.08), + color: SLATE_BUTTON_TEXT, + border: 'transparent', + }, + }, + }, + }, + + + monaco: { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'comment', foreground: '9ca2a9', fontStyle: 'italic' }, + { token: 'keyword', foreground: 'a8b4c4' }, + { token: 'string', foreground: '8fc8a9' }, + { token: 'number', foreground: 'b5c4fc' }, + { token: 'type', foreground: '9ca6b8' }, + { token: 'class', foreground: '9ca6b8' }, + { token: 'function', foreground: 'c5cad3' }, + { token: 'variable', foreground: 'c4c8ce' }, + { token: 'constant', foreground: 'b5c4fc' }, + { token: 'operator', foreground: 'a8b4c4' }, + { token: 'tag', foreground: '9ca6b8' }, + { token: 'attribute.name', foreground: 'c4c8ce' }, + { token: 'attribute.value', foreground: '8fc8a9' }, + ], + colors: { + background: SLATE_BACKGROUND_PRIMARY, + foreground: SLATE_TEXT_PRIMARY, + lineHighlight: SLATE_BACKGROUND_SECONDARY, + selection: overlayWhite(0.12), + cursor: '#aeb6c3', + }, + }, +}; diff --git a/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts new file mode 100644 index 0000000000..37e534a9e6 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + builtinAppearancePalettes, + DEFAULT_DARK_APPEARANCE_ID, + DEFAULT_LIGHT_APPEARANCE_ID, +} from './palettes'; +import { createStartupAppearanceBootstrapManifest } from './startupAppearanceBootstrap'; +import { createAppearancePromptSnapshotManifest } from './appearancePromptSnapshots'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const generatedManifestPath = path.resolve( + currentDirectory, + '../../../../../apps/desktop/src/generated/startup_appearance_bootstrap.json', +); +const generatedAppearancePromptSnapshotPath = path.resolve( + currentDirectory, + '../../../../../crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json', +); + +describe('startup appearance bootstrap manifest', () => { + it('projects only the desktop first-paint fields from builtin appearances', () => { + const manifest = createStartupAppearanceBootstrapManifest(builtinAppearancePalettes); + + expect(manifest).toMatchObject({ + schemaVersion: 1, + defaultLightAppearanceId: DEFAULT_LIGHT_APPEARANCE_ID, + defaultDarkAppearanceId: DEFAULT_DARK_APPEARANCE_ID, + }); + expect(manifest.appearances).toHaveLength(builtinAppearancePalettes.length); + expect(new Set(manifest.appearances.map(entry => entry.id)).size) + .toBe(builtinAppearancePalettes.length); + + const light = manifest.appearances.find(entry => entry.id === DEFAULT_LIGHT_APPEARANCE_ID); + const palette = builtinAppearancePalettes.find(entry => entry.id === DEFAULT_LIGHT_APPEARANCE_ID); + expect(light).toEqual({ + id: palette?.id, + bgPrimary: palette?.colors.background.primary, + bgSecondary: palette?.colors.background.secondary, + bgScene: palette?.colors.background.scene, + isLight: true, + textPrimary: palette?.colors.text.primary, + textMuted: palette?.colors.text.muted, + accentColor: palette?.colors.accent[500], + }); + }); + + it('keeps the committed desktop manifest synchronized', () => { + const generated = JSON.parse(fs.readFileSync(generatedManifestPath, 'utf8')); + expect(generated).toEqual(createStartupAppearanceBootstrapManifest(builtinAppearancePalettes)); + }); +}); + +describe('appearance prompt snapshot manifest', () => { + it('projects generative UI fields from builtin appearances', () => { + const manifest = createAppearancePromptSnapshotManifest(builtinAppearancePalettes); + expect(manifest).toMatchObject({ + schemaVersion: 1, + defaultLightAppearanceId: DEFAULT_LIGHT_APPEARANCE_ID, + defaultDarkAppearanceId: DEFAULT_DARK_APPEARANCE_ID, + }); + expect(manifest.appearances).toHaveLength(builtinAppearancePalettes.length); + expect(manifest.appearances.find(entry => entry.id === DEFAULT_DARK_APPEARANCE_ID)?.mode) + .toBe('dark'); + }); + + it('keeps the committed generative UI manifest synchronized', () => { + const generated = JSON.parse(fs.readFileSync(generatedAppearancePromptSnapshotPath, 'utf8')); + expect(generated).toEqual(createAppearancePromptSnapshotManifest(builtinAppearancePalettes)); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts new file mode 100644 index 0000000000..a2926b2c83 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts @@ -0,0 +1,48 @@ +import type { AppearancePalette, AppearancePaletteId } from './AppearancePalette'; +import { DEFAULT_DARK_APPEARANCE_ID, DEFAULT_LIGHT_APPEARANCE_ID } from './palettes'; + +export const STARTUP_APPEARANCE_BOOTSTRAP_SCHEMA_VERSION = 1 as const; + +export interface StartupAppearanceBootstrapEntry { + id: AppearancePaletteId; + bgPrimary: string; + bgSecondary: string; + bgScene: string; + isLight: boolean; + textPrimary: string; + textMuted: string; + accentColor: string; +} + +export interface StartupAppearanceBootstrapManifest { + schemaVersion: typeof STARTUP_APPEARANCE_BOOTSTRAP_SCHEMA_VERSION; + defaultLightAppearanceId: AppearancePaletteId; + defaultDarkAppearanceId: AppearancePaletteId; + appearances: StartupAppearanceBootstrapEntry[]; +} + +export function createStartupAppearanceBootstrapEntry( + palette: AppearancePalette, +): StartupAppearanceBootstrapEntry { + return { + id: palette.id, + bgPrimary: palette.colors.background.primary, + bgSecondary: palette.colors.background.secondary, + bgScene: palette.colors.background.scene, + isLight: palette.type === 'light', + textPrimary: palette.colors.text.primary, + textMuted: palette.colors.text.muted, + accentColor: palette.colors.accent[500], + }; +} + +export function createStartupAppearanceBootstrapManifest( + palettes: readonly AppearancePalette[], +): StartupAppearanceBootstrapManifest { + return { + schemaVersion: STARTUP_APPEARANCE_BOOTSTRAP_SCHEMA_VERSION, + defaultLightAppearanceId: DEFAULT_LIGHT_APPEARANCE_ID, + defaultDarkAppearanceId: DEFAULT_DARK_APPEARANCE_ID, + appearances: palettes.map(createStartupAppearanceBootstrapEntry), + }; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/tokyoNight.ts b/src/web-ui/src/infrastructure/appearance/builtins/tokyoNight.ts new file mode 100644 index 0000000000..34c705205c --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/tokyoNight.ts @@ -0,0 +1,210 @@ + + +import { AppearancePalette } from './AppearancePalette'; +import { + createAccentScale, + createCompactRadius, + createExpressiveTypography, + createGitColors, + createSemanticColors, + createSecondaryAccentScale, + createStandardEasing, + createStandardSpacing, + overlayBlack, + rgbFromHex, + rgbaFromHex, + STATIC_WHITE, +} from './paletteHelpers'; + +const TOKYO_BACKGROUND_PRIMARY = '#1a1b26'; +const TOKYO_BACKGROUND_SECONDARY = '#1e202e'; +const TOKYO_TEXT_PRIMARY = '#c0caf5'; +const TOKYO_TEXT_SECONDARY = '#a9b1d6'; +const TOKYO_TEXT_MUTED = '#787c99'; +const TOKYO_ACCENT = '#7aa2f7'; +const TOKYO_ACCENT_HOVER = '#6183bb'; +const TOKYO_PURPLE = '#bb9af7'; +const TOKYO_PURPLE_HOVER = '#9d7cd8'; +const TOKYO_SUCCESS = '#9ece6a'; +const TOKYO_WARNING = '#e0af68'; +const TOKYO_ERROR = '#f7768e'; +const TOKYO_INFO = '#7dcfff'; +const TOKYO_BORDER = '#334155'; +const TOKYO_SCROLLBAR = '#868bc4'; +const TOKYO_GIT_ADDED = '#41a6b5'; +const TOKYO_PRIMARY_BUTTON = '#3d59a1'; + +const tokyoAccent = (alpha: number | string) => rgbaFromHex(TOKYO_ACCENT, alpha); +const tokyoBorder = (alpha: number | string) => rgbaFromHex(TOKYO_BORDER, alpha); +const tokyoScrollbar = (alpha: number | string) => rgbaFromHex(TOKYO_SCROLLBAR, alpha); +const tokyoPrimaryButton = (alpha: number | string) => rgbaFromHex(TOKYO_PRIMARY_BUTTON, alpha); + +/** Colors aligned with the Tokyo Night palette (Enkia / VS Code Tokyo Night). */ +export const bitfunTokyoNightPalette: AppearancePalette = { + id: 'bitfun-tokyo-night', + name: 'Tokyo Night', + type: 'dark', + description: + 'Tokyo Night - deep indigo base with soft blue and magenta accents', + author: 'BitFun Team', + version: '1.0.0', + + colors: { + background: { + primary: TOKYO_BACKGROUND_PRIMARY, + secondary: TOKYO_BACKGROUND_SECONDARY, + tertiary: TOKYO_BACKGROUND_SECONDARY, + elevated: TOKYO_BACKGROUND_SECONDARY, + workbench: TOKYO_BACKGROUND_SECONDARY, + scene: TOKYO_BACKGROUND_PRIMARY, + }, + + text: { + primary: TOKYO_TEXT_PRIMARY, + secondary: TOKYO_TEXT_SECONDARY, + muted: TOKYO_TEXT_MUTED, + disabled: '#545c7e', + }, + + accent: createAccentScale({ + base: TOKYO_ACCENT, + hover: TOKYO_ACCENT_HOVER, + alpha: { 50: 0.05, 700: 0.85 }, + }), + + purple: createSecondaryAccentScale({ + base: TOKYO_PURPLE, + hover: TOKYO_PURPLE_HOVER, + }), + + semantic: createSemanticColors({ + success: TOKYO_SUCCESS, + warning: TOKYO_WARNING, + error: TOKYO_ERROR, + info: TOKYO_INFO, + bgAlpha: 0.12, + borderAlpha: 0.35, + }), + + border: { + subtle: tokyoBorder(0.45), + base: tokyoBorder(0.6), + medium: tokyoBorder(0.72), + strong: tokyoBorder(0.85), + prominent: tokyoAccent(0.45), + }, + + element: { + subtle: tokyoAccent(0.06), + soft: tokyoAccent(0.08), + base: tokyoAccent(0.11), + medium: tokyoAccent(0.14), + strong: tokyoAccent(0.18), + }, + + git: createGitColors({ + branch: rgbFromHex(TOKYO_ACCENT), + branchBg: tokyoAccent(0.12), + changes: rgbFromHex(TOKYO_WARNING), + added: rgbFromHex(TOKYO_GIT_ADDED), + deleted: rgbFromHex(TOKYO_ERROR), + staged: rgbFromHex(TOKYO_SUCCESS), + }), + + scrollbar: { + thumb: tokyoScrollbar(0.15), + thumbHover: tokyoScrollbar(0.28), + }, + }, + + effects: { + shadow: { + xs: `0 1px 3px ${overlayBlack(0.55)}`, + sm: `0 2px 6px ${overlayBlack(0.5)}`, + base: `0 4px 12px ${overlayBlack(0.48)}`, + lg: `0 8px 20px ${overlayBlack(0.45)}`, + xl: `0 12px 28px ${overlayBlack(0.42)}`, + }, + + blur: { + subtle: 'blur(4px) saturate(1.15)', + base: 'blur(8px) saturate(1.2)', + }, + + radius: createCompactRadius(), + + spacing: createStandardSpacing(), + + opacity: { + disabled: 0.5, + hover: 0.88, + focus: 0.96, + }, + }, + + motion: { + duration: { + instant: '0.08s', + fast: '0.12s', + base: '0.25s', + slow: '0.5s', + }, + + easing: createStandardEasing('cubic-bezier(0.25, 0.46, 0.45, 0.94)'), + }, + + typography: createExpressiveTypography(), + + components: { + button: { + + primary: { + default: { + background: tokyoPrimaryButton(0.55), + color: TOKYO_TEXT_PRIMARY, + border: tokyoAccent(0.45), + shadow: `0 0 14px ${tokyoPrimaryButton(0.35)}`, + }, + hover: { + background: tokyoPrimaryButton(0.72), + color: STATIC_WHITE, + border: tokyoAccent(0.55), + shadow: + `0 0 22px ${tokyoAccent(0.35)}, 0 4px 12px ${overlayBlack(0.35)}`, + transform: 'translateY(-2px)', + }, + active: { + background: tokyoPrimaryButton(0.62), + color: STATIC_WHITE, + border: tokyoAccent(0.48), + shadow: `0 0 18px ${tokyoAccent(0.28)}`, + transform: 'translateY(-1px)', + }, + }, + + ghost: { + default: { + color: TOKYO_TEXT_MUTED, + }, + hover: { + background: tokyoAccent(0.1), + color: TOKYO_TEXT_PRIMARY, + border: tokyoAccent(0.35), + }, + }, + }, + }, + + monaco: { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + background: TOKYO_BACKGROUND_PRIMARY, + foreground: TOKYO_TEXT_SECONDARY, + lineHighlight: TOKYO_BACKGROUND_SECONDARY, + selection: 'rgba(81, 92, 126, 0.35)', + cursor: TOKYO_TEXT_PRIMARY, + }, + }, +}; diff --git a/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts new file mode 100644 index 0000000000..4465181b4d --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts @@ -0,0 +1,829 @@ +import { describe, expect, it } from 'vitest'; +import { bitfunDarkPalette, builtinAppearancePalettes } from '../builtins/palettes'; +import { buildBuiltinAppearance } from '../builtins/buildBuiltinAppearance'; +import { composeAppearancePackage } from '../builtins/composeAppearancePackage'; +import { createDefaultAppearanceRegistry } from '../registry/defaultAppearanceRegistry'; +import { AppearancePackageValidationError } from '../schema/AppearancePackageValidationError'; +import type { AppearancePackage } from '../types'; +import { AppearanceCompiler } from './AppearanceCompiler'; + +describe('AppearanceCompiler', () => { + it('preserves structured validation diagnostics when compilation is rejected', () => { + const pkg = { + ...buildBuiltinAppearance(bitfunDarkPalette), + components: { + 'toolbar-mode': { + parts: { + sessionMenu: { base: { opacity: { kind: 'number', value: 1 } } }, + }, + }, + }, + }; + + expect(() => new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1)) + .toThrow(AppearancePackageValidationError); + }); + + it('compiles a built-in appearance into host-owned component selectors', () => { + const compiler = new AppearanceCompiler(createDefaultAppearanceRegistry()); + const snapshot = compiler.compile(buildBuiltinAppearance(bitfunDarkPalette), 3); + + expect(snapshot.id).toBe(bitfunDarkPalette.id); + expect(snapshot.revision).toBe(3); + expect(snapshot.cssText).toContain('--bf-appearance-colors-bg-primary'); + expect(snapshot.cssText).toContain('[data-bf-component="button"][data-bf-part="root"]'); + expect(snapshot.cssText).toContain('[data-bf-variant="primary"]:hover:not(:disabled)'); + expect(snapshot.cssText).not.toContain('@layer'); + expect(snapshot.cssText).not.toContain(' !important;'); + expect(snapshot.cssText).not.toContain('.btn-primary'); + }); + + it('compiles every built-in appearance without raw CSS passthrough', () => { + const compiler = new AppearanceCompiler(createDefaultAppearanceRegistry()); + builtinAppearancePalettes.forEach((palette, index) => { + const snapshot = compiler.compile(buildBuiltinAppearance(palette), index + 1); + expect(snapshot.mode).toBe(palette.type); + expect(snapshot.cssText).not.toContain('url('); + }); + }); + + it('calculates real contrast diagnostics for structured colors', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.low-contrast', + name: 'Low Contrast', + version: '1.0.0', + mode: 'light', + components: { + button: { + parts: { + root: { + base: { + backgroundColor: { kind: 'hex', value: '#ffffff' }, + color: { kind: 'hex', value: '#eeeeee' }, + }, + }, + }, + }, + }, + }; + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'LOW_CONTRAST' }), + ])); + }); + + it('uses important declarations only for explicitly overriding parts', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.cascade-override', + name: 'Cascade Override', + version: '1.0.0', + mode: 'dark', + components: { + button: { + parts: { + root: { + cascade: 'override', + base: { color: { kind: 'hex', value: '#ffffff' } }, + }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain('color:#ffffff !important;'); + }); + + it('keeps non-forceable layout declarations in the normal cascade', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.property-level-override', + name: 'Property Level Override', + version: '1.0.0', + mode: 'dark', + components: { + 'content-canvas': { + parts: { + root: { + cascade: 'override', + base: { + backgroundColor: { kind: 'hex', value: '#101010' }, + position: 'relative', + }, + }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain('background-color:#101010 !important;'); + expect(snapshot.cssText).toContain('position:relative;'); + expect(snapshot.cssText).not.toContain('position:relative !important;'); + expect(snapshot.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'OVERRIDE_PROPERTY_NOT_FORCEABLE' }), + ])); + }); + + it('normalizes side borders and outlines into deterministic CSS', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.border-normalization', + name: 'Border Normalization', + version: '1.0.0', + mode: 'light', + components: { + button: { + parts: { + root: { + base: { + borderStyle: 'solid', + borderBottomWidth: { kind: 'px', value: 1 }, + outlineWidth: { kind: 'px', value: 2 }, + outlineColor: { kind: 'hex', value: '#8866ff' }, + }, + }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain('border-width:0;border-bottom-width:1px;border-style:solid;'); + expect(snapshot.cssText).toContain('outline-color:#8866ff;outline-width:2px;outline-style:solid;'); + expect(snapshot.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'BORDER_WIDTH_NORMALIZED' }), + expect.objectContaining({ code: 'OUTLINE_STYLE_NORMALIZED' }), + ])); + }); + + it('combines materials in declaration order before applying the part base', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.material-composition', + name: 'Material Composition', + version: '1.0.0', + mode: 'dark', + materials: { + surface: { + visualRole: 'control', + style: { + backgroundColor: { kind: 'hex', value: '#202020' }, + borderRadius: { kind: 'px', value: 6 }, + }, + }, + accent: { + visualRole: 'control', + style: { + color: { kind: 'hex', value: '#ff66cc' }, + backgroundColor: { kind: 'hex', value: '#303030' }, + }, + }, + }, + components: { + button: { + parts: { + root: { + materials: ['surface', 'accent'], + base: { backgroundColor: { kind: 'hex', value: '#404040' } }, + }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain('background-color:#404040;'); + expect(snapshot.cssText).toContain('color:#ff66cc;'); + expect(snapshot.cssText).toContain('border-radius:6px;'); + }); + + it('does not promote inherited baseline declarations when an imported part overrides its own fields', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.layered-cascade', + name: 'Layered Cascade', + version: '1.0.0', + mode: 'light', + components: { + button: { + parts: { + root: { + cascade: 'override', + base: { borderRadius: { kind: 'px', value: 2 } }, + }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile( + composeAppearancePackage(pkg), + 1, + ); + expect(snapshot.cssText).toContain('border-radius:2px !important;'); + expect(snapshot.cssText).toContain('display:flex;'); + expect(snapshot.cssText).not.toContain('display:flex !important;'); + }); + + it('compiles structured layout, media, transform, and filter values without CSS passthrough', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.structured-style', + name: 'Structured Style', + version: '1.0.0', + mode: 'dark', + components: { + 'content-canvas': { + parts: { + root: { + base: { + display: 'grid', + width: { kind: 'lengthKeyword', value: 'fit-content' }, + paddingTop: { kind: 'vh', value: 2 }, + marginInline: { kind: 'lengthKeyword', value: 'auto' }, + gridTemplateColumns: { + kind: 'gridTracks', + tracks: [{ kind: 'fr', value: 1 }, { kind: 'px', value: 24 }], + }, + aspectRatio: { kind: 'ratio', width: 16, height: 9 }, + objectFit: 'cover', + justifyItems: 'center', + order: { kind: 'number', value: 2 }, + caretColor: { kind: 'hex', value: '#33ffaa' }, + transform: { kind: 'transform', rotate: 12, scale: 1.05 }, + filter: [ + { kind: 'blur', value: { kind: 'px', value: 2 } }, + { kind: 'saturate', value: { kind: 'number', value: 1.2 } }, + ], + }, + }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain('grid-template-columns:1fr 24px;'); + expect(snapshot.cssText).toContain('width:fit-content;'); + expect(snapshot.cssText).toContain('padding-top:2vh;'); + expect(snapshot.cssText).toContain('margin-inline:auto;'); + expect(snapshot.cssText).toContain('justify-items:center;'); + expect(snapshot.cssText).toContain('order:2;'); + expect(snapshot.cssText).toContain('caret-color:#33ffaa;'); + expect(snapshot.cssText).toContain('aspect-ratio:16 / 9;'); + expect(snapshot.cssText).toContain('object-fit:cover;'); + expect(snapshot.cssText).toContain('transform:rotate(12deg) scale(1.05);'); + expect(snapshot.cssText).toContain('filter:blur(2px) saturate(1.2);'); + }); + + it('compiles layered package assets into aligned host-owned background declarations', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.layered-backgrounds', + name: 'Layered Backgrounds', + version: '1.0.0', + mode: 'light', + assets: { + backdrop: { kind: 'image', mimeType: 'image/webp', source: { kind: 'package', path: 'assets/backdrop.webp' } }, + texture: { kind: 'image', mimeType: 'image/png', source: { kind: 'package', path: 'assets/texture.png' } }, + ornament: { kind: 'image', mimeType: 'image/png', source: { kind: 'package', path: 'assets/ornament.png' } }, + }, + components: { + card: { + parts: { + root: { + base: { + backgroundImages: [ + { kind: 'asset', assetId: 'ornament' }, + { kind: 'asset', assetId: 'texture' }, + { kind: 'asset', assetId: 'backdrop' }, + ], + backgroundSizes: [ + { kind: 'backgroundSize', width: { kind: 'px', value: 96 }, height: { kind: 'lengthKeyword', value: 'auto' } }, + { kind: 'backgroundSize', width: { kind: 'px', value: 256 }, height: { kind: 'lengthKeyword', value: 'auto' } }, + 'cover', + ], + backgroundPositions: [ + { kind: 'backgroundPosition', x: { kind: 'percent', value: 100 }, y: { kind: 'percent', value: 100 } }, + { kind: 'backgroundPosition', x: { kind: 'percent', value: 0 }, y: { kind: 'percent', value: 0 } }, + { kind: 'backgroundPosition', x: { kind: 'percent', value: 65 }, y: { kind: 'percent', value: 50 } }, + ], + backgroundRepeats: ['no-repeat', 'repeat', 'no-repeat'], + backgroundBlendModes: ['normal', 'soft-light', 'normal'], + }, + }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain( + 'background-image:var(--bf-appearance-asset-ornament, none), var(--bf-appearance-asset-texture, none), var(--bf-appearance-asset-backdrop, none);', + ); + expect(snapshot.cssText).toContain('background-size:96px auto, 256px auto, cover;'); + expect(snapshot.cssText).toContain('background-position:100% 100%, 0% 0%, 65% 50%;'); + expect(snapshot.cssText).toContain('background-repeat:no-repeat, repeat, no-repeat;'); + expect(snapshot.cssText).toContain('background-blend-mode:normal, soft-light, normal;'); + expect(snapshot.cssText).not.toContain('url('); + }); + + it('compiles ancestor-owned state selectors for descendant parts', () => { + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.ancestor-state', + name: 'Ancestor State', + version: '1.0.0', + mode: 'dark', + components: { + checkbox: { + parts: { + box: { + states: { + checked: { backgroundColor: { kind: 'hex', value: '#22cc88' } }, + }, + }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain( + '[data-bf-component="checkbox"][data-bf-part="root"]:has(input:checked) [data-bf-component="checkbox"][data-bf-part="box"]', + ); + }); + + it('targets multiple parts, facets, and states across complex product surfaces', () => { + const accent = { kind: 'hex', value: '#ff3366' } as const; + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.product-surfaces', + name: 'Product Surfaces', + version: '1.0.0', + mode: 'dark', + components: { + 'about-dialog': { + parts: { + hero: { base: { backgroundColor: accent } }, + title: { base: { color: { kind: 'hex', value: '#ffffff' } } }, + progressFill: { states: { downloading: { backgroundColor: accent } } }, + }, + }, + 'nav-panel': { + parts: { + topAction: { + contexts: [{ + when: { facets: { action: 'assistant' }, states: ['active'] }, + style: { backgroundColor: accent }, + }], + }, + }, + }, + 'canvas-editor-area': { + parts: { + root: { + facets: { layout: { grid: { gap: { kind: 'px', value: 8 } } } }, + }, + }, + }, + 'canvas-tab': { + parts: { + root: { states: { active: { borderBottomColor: accent } } }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain('[data-bf-component="about-dialog"][data-bf-part="hero"]'); + expect(snapshot.cssText).toContain('[data-bf-component="about-dialog"][data-bf-part="updateCard"][data-bf-state~="downloading"] [data-bf-component="about-dialog"][data-bf-part="progressFill"]'); + expect(snapshot.cssText).toContain('[data-bf-component="nav-panel"][data-bf-part="topAction"][data-bf-action="assistant"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="canvas-editor-area"][data-bf-part="root"][data-bf-layout="grid"]'); + expect(snapshot.cssText).toContain('[data-bf-component="canvas-tab"][data-bf-part="root"][data-bf-state~="active"]'); + }); + + it('compiles dedicated contracts for large interactive owners', () => { + const accent = { kind: 'hex', value: '#33ffaa' } as const; + const pkg: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.large-owners', + name: 'Large Owners', + version: '1.0.0', + mode: 'dark', + components: { + 'chat-input': { + parts: { + target: { + contexts: [{ + when: { facets: { target: 'btw' }, states: ['selected'] }, + style: { backgroundColor: accent }, + }], + }, + }, + }, + 'virtual-message-list': { + parts: { + root: { + facets: { renderMode: { static: { backgroundColor: accent } } }, + }, + boundaryStatus: { + states: { unavailable: { color: accent } }, + }, + }, + }, + 'ai-model-config': { + parts: { + root: { + facets: { view: { selection: { backgroundColor: accent } } }, + }, + }, + }, + 'external-sources-config': { + parts: { + conflict: { base: { borderColor: accent } }, + }, + }, + 'review-platform': { + parts: { + listItem: { states: { selected: { backgroundColor: accent } } }, + }, + }, + 'modern-flow-chat': { + parts: { + historyOpenIntent: { base: { backgroundColor: accent } }, + }, + }, + 'session-config': { + parts: { + templateCard: { states: { expanded: { borderColor: accent } } }, + }, + }, + 'mcp-tools-config': { + parts: { + root: { facets: { view: { json: { backgroundColor: accent } } } }, + authEditor: { base: { borderColor: accent } }, + }, + }, + 'sessions-section': { + parts: { + row: { states: { active: { backgroundColor: accent } } }, + }, + }, + 'files-panel': { + parts: { + search: { facets: { searchMode: { content: { borderColor: accent } } } }, + }, + }, + 'remote-connect-dialog': { + parts: { + groupTab: { + contexts: [{ + when: { facets: { group: 'account' }, states: ['active'] }, + style: { backgroundColor: accent }, + }], + }, + }, + }, + 'session-usage-panel': { + parts: { + tab: { + contexts: [{ + when: { facets: { tab: 'models' }, states: ['active'] }, + style: { color: accent }, + }], + }, + }, + }, + 'file-operation-tool-card': { + parts: { + root: { + contexts: [{ + when: { facets: { action: 'modify' }, states: ['expanded'] }, + style: { borderColor: accent }, + }], + }, + }, + }, + 'acp-agents-config': { + parts: { + root: { facets: { view: { json: { backgroundColor: accent } } } }, + remoteServer: { base: { borderColor: accent } }, + }, + }, + 'tiptap-editor': { + parts: { + inlineAiPanel: { base: { backgroundColor: accent } }, + }, + }, + 'scheduled-jobs-view': { + parts: { + root: { facets: { target: { workspace: { backgroundColor: accent } } } }, + job: { states: { expanded: { borderColor: accent } } }, + }, + }, + 'deep-review-action-bar': { + parts: { + root: { + contexts: [{ + when: { facets: { phase: 'review_completed', variant: 'success' } }, + style: { borderColor: accent }, + }], + }, + }, + }, + 'rich-text-input': { + parts: { + contextTag: { facets: { contextType: { 'widget-reference': { backgroundColor: accent } } } }, + }, + }, + 'model-round-item': { + parts: { + root: { facets: { status: { streaming: { backgroundColor: accent } } } }, + action: { states: { copied: { color: accent } } }, + }, + }, + 'flexible-panel': { + parts: { + code: { states: { needsFix: { borderColor: accent } } }, + }, + }, + 'btw-session-panel': { + parts: { + root: { facets: { view: { session: { backgroundColor: accent } } } }, + }, + }, + 'model-selector': { + parts: { + trigger: { states: { open: { borderColor: accent } } }, + }, + }, + 'flow-chat-header': { + parts: { + turnItem: { states: { active: { backgroundColor: accent } } }, + }, + }, + 'session-files-badge': { + parts: { + file: { facets: { operation: { modify: { borderColor: accent } } } }, + }, + }, + 'code-review-tool-card': { + parts: { + group: { states: { expanded: { backgroundColor: accent } } }, + }, + }, + 'create-agent-page': { + parts: { + levelOption: { states: { active: { backgroundColor: accent } } }, + }, + }, + 'keyboard-shortcuts': { + parts: { + keyBadge: { states: { recording: { borderColor: accent } } }, + }, + }, + 'task-tool-display': { + parts: { + root: { states: { failed: { borderColor: accent } } }, + }, + }, + 'basics-config': { + parts: { + notifications: { base: { backgroundColor: accent } }, + }, + }, + 'markdown-editor': { + parts: { + root: { facets: { view: { source: { backgroundColor: accent } } } }, + }, + }, + 'plan-viewer': { + parts: { + editorPanel: { states: { expanded: { borderColor: accent } } }, + }, + }, + 'terminal-tool': { + parts: { + screen: { base: { backgroundColor: accent } }, + }, + }, + 'app-layout': { + parts: { + root: { states: { toolbar: { backgroundColor: accent } } }, + }, + }, + 'skill-group-picker': { + parts: { + token: { states: { selected: { borderColor: accent } } }, + }, + }, + 'working-copy-view': { + parts: { + file: { states: { selected: { backgroundColor: accent } } }, + }, + }, + 'assistant-config-page': { + parts: { + persona: { states: { selected: { borderColor: accent } } }, + }, + }, + 'assistant-defaults-page': { + parts: { + skill: { states: { covered: { backgroundColor: accent } } }, + }, + }, + 'task-detail-panel': { + parts: { + root: { states: { empty: { backgroundColor: accent } } }, + }, + }, + 'toolbar-mode': { + parts: { + root: { states: { expanded: { borderColor: accent } } }, + }, + }, + 'mcp-tool-display': { + parts: { + expanded: { states: { expanded: { backgroundColor: accent } } }, + }, + }, + 'terminal-tool-card': { + parts: { + root: { facets: { status: { running: { borderColor: accent } } } }, + }, + }, + 'skills-config': { + parts: { + marketItem: { states: { installed: { borderColor: accent } } }, + }, + }, + 'diff-editor': { + parts: { + loading: { states: { loading: { backgroundColor: accent } } }, + }, + }, + 'agent-companion-desktop-pet': { + parts: { hitbox: { states: { attention: { borderColor: accent } } } }, + }, + 'tool-group-picker': { + parts: { token: { states: { selected: { backgroundColor: accent } } } }, + }, + 'inline-diff-preview': { + parts: { root: { states: { empty: { backgroundColor: accent } } } }, + }, + 'export-image': { + parts: { trigger: { states: { exporting: { color: accent } } } }, + }, + 'user-message-item': { + parts: { root: { states: { failed: { borderColor: accent } } } }, + }, + 'session-usage-report-card': { + parts: { loading: { states: { loading: { backgroundColor: accent } } } }, + }, + 'ask-user-question-card': { + parts: { root: { states: { completed: { borderColor: accent } } } }, + }, + 'create-plan-display': { + parts: { todos: { states: { expanded: { backgroundColor: accent } } } }, + }, + 'exec-process-tool-card': { + parts: { waiting: { states: { waiting: { backgroundColor: accent } } } }, + }, + 'editor-config': { + parts: { saving: { states: { saving: { color: accent } } } }, + }, + 'workspace-project-permissions-dialog': { + parts: { rule: { base: { borderColor: accent } } }, + }, + 'workspace-session-batch-modal': { + parts: { row: { states: { selected: { backgroundColor: accent } } } }, + }, + 'archived-sessions-config': { + parts: { group: { states: { collapsed: { borderColor: accent } } } }, + }, + 'settings-nav': { + parts: { item: { states: { active: { backgroundColor: accent } } } }, + }, + 'background-command-output-panel': { + parts: { root: { states: { error: { borderColor: accent } } } }, + }, + 'chat-input-pixel-pet': { + parts: { root: { facets: { mood: { working: { backgroundColor: accent } } } } }, + }, + 'file-mention-picker': { + parts: { item: { states: { selected: { backgroundColor: accent } } } }, + }, + 'session-file-modifications-bar': { + parts: { file: { facets: { operation: { edit: { borderColor: accent } } } } }, + }, + 'editor-breadcrumb': { + parts: { item: { states: { active: { backgroundColor: accent } } } }, + }, + 'git-branch-history': { + parts: { commit: { states: { expanded: { borderColor: accent } } } }, + }, + 'git-diff-view': { + parts: { typeOption: { states: { active: { backgroundColor: accent } } } }, + }, + 'git-settings-view': { + parts: { status: { states: { error: { borderColor: accent } } } }, + }, + }, + scenes: { + agents: { + parts: { + coreGrid: { base: { borderColor: accent } }, + }, + }, + }, + }; + + const snapshot = new AppearanceCompiler(createDefaultAppearanceRegistry()).compile(pkg, 1); + expect(snapshot.cssText).toContain('[data-bf-component="chat-input"][data-bf-part="target"][data-bf-target="btw"][data-bf-state~="selected"]'); + expect(snapshot.cssText).toContain('[data-bf-component="virtual-message-list"][data-bf-part="root"][data-bf-render-mode="static"]'); + expect(snapshot.cssText).toContain('[data-bf-component="virtual-message-list"][data-bf-part="boundaryStatus"][data-bf-state~="unavailable"]'); + expect(snapshot.cssText).toContain('[data-bf-component="ai-model-config"][data-bf-part="root"][data-bf-view="selection"]'); + expect(snapshot.cssText).toContain('[data-bf-component="external-sources-config"][data-bf-part="conflict"]'); + expect(snapshot.cssText).toContain('[data-bf-component="review-platform"][data-bf-part="listItem"][data-bf-state~="selected"]'); + expect(snapshot.cssText).toContain('[data-bf-component="modern-flow-chat"][data-bf-part="historyOpenIntent"]'); + expect(snapshot.cssText).toContain('[data-bf-component="session-config"][data-bf-part="templateCard"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="mcp-tools-config"][data-bf-part="root"][data-bf-view="json"]'); + expect(snapshot.cssText).toContain('[data-bf-component="mcp-tools-config"][data-bf-part="authEditor"]'); + expect(snapshot.cssText).toContain('[data-bf-component="sessions-section"][data-bf-part="row"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="files-panel"][data-bf-part="search"][data-bf-search-mode="content"]'); + expect(snapshot.cssText).toContain('[data-bf-component="remote-connect-dialog"][data-bf-part="groupTab"][data-bf-group="account"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-scene="agents"][data-bf-part="coreGrid"]'); + expect(snapshot.cssText).toContain('[data-bf-component="session-usage-panel"][data-bf-part="tab"][data-bf-tab="models"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="file-operation-tool-card"][data-bf-part="root"][data-bf-action="modify"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="acp-agents-config"][data-bf-part="root"][data-bf-view="json"]'); + expect(snapshot.cssText).toContain('[data-bf-component="acp-agents-config"][data-bf-part="remoteServer"]'); + expect(snapshot.cssText).toContain('[data-bf-component="tiptap-editor"][data-bf-part="inlineAiPanel"]'); + expect(snapshot.cssText).toContain('[data-bf-component="scheduled-jobs-view"][data-bf-part="root"][data-bf-target="workspace"]'); + expect(snapshot.cssText).toContain('[data-bf-component="scheduled-jobs-view"][data-bf-part="job"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="deep-review-action-bar"][data-bf-part="root"][data-bf-phase="review_completed"][data-bf-variant="success"]'); + expect(snapshot.cssText).toContain('[data-bf-component="rich-text-input"][data-bf-part="contextTag"][data-bf-context-type="widget-reference"]'); + expect(snapshot.cssText).toContain('[data-bf-component="model-round-item"][data-bf-part="root"][data-bf-status="streaming"]'); + expect(snapshot.cssText).toContain('[data-bf-component="model-round-item"][data-bf-part="action"][data-bf-state~="copied"]'); + expect(snapshot.cssText).toContain('[data-bf-component="flexible-panel"][data-bf-part="code"][data-bf-state~="needsFix"]'); + expect(snapshot.cssText).toContain('[data-bf-component="btw-session-panel"][data-bf-part="root"][data-bf-view="session"]'); + expect(snapshot.cssText).toContain('[data-bf-component="model-selector"][data-bf-part="trigger"][data-bf-state~="open"]'); + expect(snapshot.cssText).toContain('[data-bf-component="flow-chat-header"][data-bf-part="turnItem"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="session-files-badge"][data-bf-part="file"][data-bf-operation="modify"]'); + expect(snapshot.cssText).toContain('[data-bf-component="code-review-tool-card"][data-bf-part="group"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="create-agent-page"][data-bf-part="levelOption"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="keyboard-shortcuts"][data-bf-part="keyBadge"][data-bf-state~="recording"]'); + expect(snapshot.cssText).toContain('[data-bf-component="task-tool-display"][data-bf-part="root"][data-bf-state~="failed"]'); + expect(snapshot.cssText).toContain('[data-bf-component="basics-config"][data-bf-part="notifications"]'); + expect(snapshot.cssText).toContain('[data-bf-component="markdown-editor"][data-bf-part="root"][data-bf-view="source"]'); + expect(snapshot.cssText).toContain('[data-bf-component="plan-viewer"][data-bf-part="editorPanel"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="terminal-tool"][data-bf-part="screen"]'); + expect(snapshot.cssText).toContain('[data-bf-component="app-layout"][data-bf-part="root"][data-bf-state~="toolbar"]'); + expect(snapshot.cssText).toContain('[data-bf-component="skill-group-picker"][data-bf-part="token"][data-bf-state~="selected"]'); + expect(snapshot.cssText).toContain('[data-bf-component="working-copy-view"][data-bf-part="file"][data-bf-state~="selected"]'); + expect(snapshot.cssText).toContain('[data-bf-component="assistant-config-page"][data-bf-part="persona"][data-bf-state~="selected"]'); + expect(snapshot.cssText).toContain('[data-bf-component="assistant-defaults-page"][data-bf-part="skill"][data-bf-state~="covered"]'); + expect(snapshot.cssText).toContain('[data-bf-component="task-detail-panel"][data-bf-part="root"][data-bf-state~="empty"]'); + expect(snapshot.cssText).toContain('[data-bf-component="toolbar-mode"][data-bf-part="root"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="mcp-tool-display"][data-bf-part="expanded"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="terminal-tool-card"][data-bf-part="root"][data-bf-status="running"]'); + expect(snapshot.cssText).toContain('[data-bf-component="skills-config"][data-bf-part="marketItem"][data-bf-state~="installed"]'); + expect(snapshot.cssText).toContain('[data-bf-component="diff-editor"][data-bf-part="loading"][data-bf-state~="loading"]'); + expect(snapshot.cssText).toContain('[data-bf-component="agent-companion-desktop-pet"][data-bf-part="hitbox"][data-bf-state~="attention"]'); + expect(snapshot.cssText).toContain('[data-bf-component="tool-group-picker"][data-bf-part="token"][data-bf-state~="selected"]'); + expect(snapshot.cssText).toContain('[data-bf-component="inline-diff-preview"][data-bf-part="root"][data-bf-state~="empty"]'); + expect(snapshot.cssText).toContain('[data-bf-component="export-image"][data-bf-part="trigger"][data-bf-state~="exporting"]'); + expect(snapshot.cssText).toContain('[data-bf-component="user-message-item"][data-bf-part="root"][data-bf-state~="failed"]'); + expect(snapshot.cssText).toContain('[data-bf-component="session-usage-report-card"][data-bf-part="loading"][data-bf-state~="loading"]'); + expect(snapshot.cssText).toContain('[data-bf-component="ask-user-question-card"][data-bf-part="root"][data-bf-state~="completed"]'); + expect(snapshot.cssText).toContain('[data-bf-component="create-plan-display"][data-bf-part="todos"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="exec-process-tool-card"][data-bf-part="waiting"][data-bf-state~="waiting"]'); + expect(snapshot.cssText).toContain('[data-bf-component="editor-config"][data-bf-part="saving"][data-bf-state~="saving"]'); + expect(snapshot.cssText).toContain('[data-bf-component="workspace-project-permissions-dialog"][data-bf-part="rule"]'); + expect(snapshot.cssText).toContain('[data-bf-component="workspace-session-batch-modal"][data-bf-part="row"][data-bf-state~="selected"]'); + expect(snapshot.cssText).toContain('[data-bf-component="archived-sessions-config"][data-bf-part="group"][data-bf-state~="collapsed"]'); + expect(snapshot.cssText).toContain('[data-bf-component="settings-nav"][data-bf-part="item"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="background-command-output-panel"][data-bf-part="root"][data-bf-state~="error"]'); + expect(snapshot.cssText).toContain('[data-bf-component="chat-input-pixel-pet"][data-bf-part="root"][data-bf-mood="working"]'); + expect(snapshot.cssText).toContain('[data-bf-component="file-mention-picker"][data-bf-part="item"][data-bf-state~="selected"]'); + expect(snapshot.cssText).toContain('[data-bf-component="session-file-modifications-bar"][data-bf-part="file"][data-bf-operation="edit"]'); + expect(snapshot.cssText).toContain('[data-bf-component="editor-breadcrumb"][data-bf-part="item"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="git-branch-history"][data-bf-part="commit"][data-bf-state~="expanded"]'); + expect(snapshot.cssText).toContain('[data-bf-component="git-diff-view"][data-bf-part="typeOption"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="git-settings-view"][data-bf-part="status"][data-bf-state~="error"]'); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.ts b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.ts new file mode 100644 index 0000000000..de9d0eff03 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.ts @@ -0,0 +1,679 @@ +import type { AppearanceRegistry } from '../registry/AppearanceRegistry'; +import { appearancePackageValidator } from '../schema/AppearancePackageValidator'; +import { AppearancePackageValidationError } from '../schema/AppearancePackageValidationError'; +import { getAppearanceCompositionLayers } from '../builtins/composeAppearancePackage'; +import type { + AppearanceDiagnostic, + AppearanceEasingValue, + AppearanceGlobalTokens, + AppearancePackage, + AppearancePartRule, + AppearanceReference, + AppearanceShadowValue, + AppearanceStyle, + AppearanceStyleProperty, + AppearanceSurfaceDefinition, + AppearanceSurfaceDescriptor, + ResolvedAppearance, + ResolvedAppearanceStyle, + ResolvedAppearanceSurface, +} from '../types'; + +const CSS_PROPERTIES: Record = { + backgroundColor: 'background-color', + backgroundImage: 'background-image', + backgroundSize: 'background-size', + backgroundPosition: 'background-position', + backgroundRepeat: 'background-repeat', + backgroundImages: 'background-image', + backgroundSizes: 'background-size', + backgroundPositions: 'background-position', + backgroundRepeats: 'background-repeat', + backgroundBlendModes: 'background-blend-mode', + color: 'color', + caretColor: 'caret-color', + accentColor: 'accent-color', + textDecorationColor: 'text-decoration-color', + borderColor: 'border-color', + borderTopColor: 'border-top-color', + borderRightColor: 'border-right-color', + borderBottomColor: 'border-bottom-color', + borderLeftColor: 'border-left-color', + borderWidth: 'border-width', + borderTopWidth: 'border-top-width', + borderRightWidth: 'border-right-width', + borderBottomWidth: 'border-bottom-width', + borderLeftWidth: 'border-left-width', + borderStyle: 'border-style', + borderRadius: 'border-radius', + borderTopLeftRadius: 'border-top-left-radius', + borderTopRightRadius: 'border-top-right-radius', + borderBottomRightRadius: 'border-bottom-right-radius', + borderBottomLeftRadius: 'border-bottom-left-radius', + boxSizing: 'box-sizing', + outlineColor: 'outline-color', + outlineWidth: 'outline-width', + outlineOffset: 'outline-offset', + outlineStyle: 'outline-style', + boxShadow: 'box-shadow', + opacity: 'opacity', + fontFamily: 'font-family', + fontSize: 'font-size', + fontWeight: 'font-weight', + fontStyle: 'font-style', + fontVariantNumeric: 'font-variant-numeric', + lineHeight: 'line-height', + letterSpacing: 'letter-spacing', + textAlign: 'text-align', + verticalAlign: 'vertical-align', + textIndent: 'text-indent', + textDecoration: 'text-decoration', + textTransform: 'text-transform', + textOverflow: 'text-overflow', + whiteSpace: 'white-space', + wordBreak: 'word-break', + overflowWrap: 'overflow-wrap', + display: 'display', + flexDirection: 'flex-direction', + flexWrap: 'flex-wrap', + flexGrow: 'flex-grow', + flexShrink: 'flex-shrink', + flexBasis: 'flex-basis', + alignItems: 'align-items', + alignContent: 'align-content', + alignSelf: 'align-self', + justifyContent: 'justify-content', + justifySelf: 'justify-self', + justifyItems: 'justify-items', + placeItems: 'place-items', + placeContent: 'place-content', + order: 'order', + gridTemplateColumns: 'grid-template-columns', + gridTemplateRows: 'grid-template-rows', + gridAutoFlow: 'grid-auto-flow', + gridColumnSpan: 'grid-column', + gridRowSpan: 'grid-row', + gap: 'gap', + rowGap: 'row-gap', + columnGap: 'column-gap', + width: 'width', + minWidth: 'min-width', + maxWidth: 'max-width', + height: 'height', + minHeight: 'min-height', + maxHeight: 'max-height', + padding: 'padding', + paddingBlock: 'padding-block', + paddingInline: 'padding-inline', + paddingTop: 'padding-top', + paddingRight: 'padding-right', + paddingBottom: 'padding-bottom', + paddingLeft: 'padding-left', + margin: 'margin', + marginBlock: 'margin-block', + marginInline: 'margin-inline', + marginTop: 'margin-top', + marginRight: 'margin-right', + marginBottom: 'margin-bottom', + marginLeft: 'margin-left', + position: 'position', + insetBlock: 'inset-block', + insetInline: 'inset-inline', + top: 'top', + right: 'right', + bottom: 'bottom', + left: 'left', + zIndex: 'z-index', + aspectRatio: 'aspect-ratio', + overflow: 'overflow', + overflowX: 'overflow-x', + overflowY: 'overflow-y', + cursor: 'cursor', + transition: 'transition', + transform: 'transform', + filter: 'filter', + backdropBlur: 'backdrop-filter', + objectFit: 'object-fit', + objectPosition: 'object-position', + mixBlendMode: 'mix-blend-mode', + isolation: 'isolation', +}; + +interface CompileContext { + pkg: AppearancePackage; + revision: number; + globals: Map; + concreteGlobals: Map; + diagnostics: AppearanceDiagnostic[]; +} + +interface CompiledRule { + selector: string; + style: ResolvedAppearanceStyle; + important: boolean; + forceableProperties: ReadonlySet; +} + +export class AppearanceCompiler { + constructor(private readonly registry: AppearanceRegistry) {} + + compile(input: unknown, revision: number): ResolvedAppearance { + const validation = appearancePackageValidator.validate(input, this.registry); + if (!validation.valid || !validation.value) { + throw new AppearancePackageValidationError(validation.errors); + } + const pkg = validation.value; + const globals = this.flattenGlobals(pkg.globals); + const context: CompileContext = { + pkg, + revision, + globals, + concreteGlobals: new Map(), + diagnostics: validation.warnings.map(issue => ({ level: 'warning', ...issue })), + }; + const globalDeclarations = this.compileGlobalDeclarations(context); + const materials = Object.fromEntries( + Object.entries(pkg.materials ?? {}).map(([id, definition]) => [ + id, + this.normalizeResolvedStyle(this.serializeStyle(definition.style, context)), + ]), + ); + const compositionLayers = getAppearanceCompositionLayers(input as AppearancePackage); + const componentResult = this.compileSurfaceLayers( + compositionLayers + ? [compositionLayers.base.components ?? {}, compositionLayers.override.components ?? {}] + : [pkg.components ?? {}], + id => this.registry.getComponent(id), + 'data-bf-component', + materials, + context, + ); + const sceneResult = this.compileSurfaceLayers( + compositionLayers + ? [compositionLayers.base.scenes ?? {}, compositionLayers.override.scenes ?? {}] + : [pkg.scenes ?? {}], + id => this.registry.getScene(id), + 'data-bf-scene', + materials, + context, + ); + + const rootSelector = `:root[data-bf-appearance="${pkg.id}"][data-bf-appearance-revision="${revision}"]`; + const appearanceRules = [ + `${rootSelector}{${globalDeclarations}}`, + ...componentResult.rules.map(rule => this.renderRule(rule)), + ...sceneResult.rules.map(rule => this.renderRule(rule)), + ].filter(Boolean).join('\n'); + const cssText = appearanceRules; + + return Object.freeze({ + revision, + id: pkg.id, + name: pkg.name, + version: pkg.version, + mode: pkg.mode, + backgroundMedia: pkg.backgroundMedia ? Object.freeze({ + ...pkg.backgroundMedia, + fit: pkg.backgroundMedia.fit ?? 'cover', + position: pkg.backgroundMedia.position ?? 'center', + }) : undefined, + globals: Object.freeze(Object.fromEntries( + [...globals.keys()].map(path => [path, this.resolveConcreteReference(path, context, new Set())]), + )), + materials: Object.freeze(materials), + components: Object.freeze(componentResult.surfaces), + scenes: Object.freeze(sceneResult.surfaces), + renderers: Object.freeze(Object.fromEntries( + Object.entries(pkg.renderers ?? {}).map(([id, definition]) => [id, Object.freeze({ ...definition.settings })]), + )), + assets: Object.freeze(Object.fromEntries( + Object.entries(pkg.assets ?? {}).map(([id, definition]) => [id, Object.freeze({ definition })]), + )), + diagnostics: Object.freeze(context.diagnostics), + cssText, + }); + } + + private compileSurfaceLayers( + layers: Record[], + getDescriptor: (id: string) => AppearanceSurfaceDescriptor | undefined, + surfaceAttribute: 'data-bf-component' | 'data-bf-scene', + materials: Record, + context: CompileContext, + ): { surfaces: Record; rules: CompiledRule[] } { + const surfaces: Record = {}; + const rules: CompiledRule[] = []; + layers.forEach(layer => { + const compiled = this.compileSurfaces(layer, getDescriptor, surfaceAttribute, materials, context); + rules.push(...compiled.rules); + Object.entries(compiled.surfaces).forEach(([surfaceId, surface]) => { + const existing = surfaces[surfaceId]; + const parts: Record = { ...(existing?.parts ?? {}) }; + Object.entries(surface.parts).forEach(([partId, styles]) => { + parts[partId] = [...(parts[partId] ?? []), ...styles]; + }); + surfaces[surfaceId] = Object.freeze({ parts: Object.freeze(parts) }); + }); + }); + return { surfaces, rules }; + } + + private flattenGlobals(globals: AppearanceGlobalTokens | undefined): Map { + const flattened = new Map(); + Object.entries(globals ?? {}).forEach(([group, values]) => { + Object.entries(values ?? {}).forEach(([id, value]) => { + flattened.set(`globals.${group}.${id}`, value); + }); + }); + return flattened; + } + + private compileGlobalDeclarations(context: CompileContext): string { + return [...context.globals.entries()] + .map(([path, value]) => `${this.referenceToVariable(path)}:${this.serializeValue(value, context)};`) + .join(''); + } + + private compileSurfaces( + definitions: Record, + getDescriptor: (id: string) => AppearanceSurfaceDescriptor | undefined, + surfaceAttribute: 'data-bf-component' | 'data-bf-scene', + materials: Record, + context: CompileContext, + ): { surfaces: Record; rules: CompiledRule[] } { + const surfaces: Record = {}; + const rules: CompiledRule[] = []; + Object.entries(definitions).forEach(([surfaceId, definition]) => { + const descriptor = getDescriptor(surfaceId); + if (!descriptor) return; + const resolvedParts: Record = {}; + Object.entries(definition.parts).forEach(([partId, partRule]) => { + const baseSelector = `:root[data-bf-appearance="${context.pkg.id}"][data-bf-appearance-revision="${context.revision}"] [${surfaceAttribute}="${surfaceId}"][data-bf-part="${partId}"]`; + const compiled = this.compilePart(baseSelector, partRule, descriptor, materials, context); + resolvedParts[partId] = compiled.map(rule => rule.style); + rules.push(...compiled); + }); + surfaces[surfaceId] = Object.freeze({ parts: Object.freeze(resolvedParts) }); + }); + return { surfaces, rules }; + } + + private compilePart( + baseSelector: string, + rule: AppearancePartRule, + descriptor: AppearanceSurfaceDescriptor, + materials: Record, + context: CompileContext, + ): CompiledRule[] { + const rules: CompiledRule[] = []; + const important = rule.cascade === 'override'; + const surfaceMatch = /^(.*) \[(data-bf-(?:component|scene))="([^"]+)"\]\[data-bf-part="([^"]+)"\]$/.exec(baseSelector); + if (!surfaceMatch) throw new Error(`Invalid host Appearance selector: ${baseSelector}`); + const [, rootSelector, surfaceAttribute, surfaceId, partId] = surfaceMatch; + const partDescriptor = descriptor.parts.find(candidate => candidate.id === partId); + if (!partDescriptor) throw new Error(`Unknown Appearance part descriptor: ${surfaceId}.${partId}`); + const forceableProperties = new Set(partDescriptor.forceableProperties ?? []); + const compileRule = (selector: string, style: ResolvedAppearanceStyle): CompiledRule => { + const normalized = this.normalizeResolvedStyle(style); + if (important) { + Object.keys(normalized).forEach(property => { + if (forceableProperties.has(property as AppearanceStyleProperty)) return; + context.diagnostics.push({ + level: 'warning', + code: 'OVERRIDE_PROPERTY_NOT_FORCEABLE', + path: selector, + message: `${property} remains in normal cascade because the host contract does not allow forcing it`, + }); + }); + } + return { selector, style: normalized, important, forceableProperties }; + }; + const selectorFor = ( + facets: Record = {}, + stateIds: readonly string[] = [], + ): string => { + let targetSelector = `[${surfaceAttribute}="${surfaceId}"][data-bf-part="${partId}"]`; + Object.entries(facets).forEach(([facetId, option]) => { + const facet = descriptor.facets?.find(candidate => candidate.id === facetId); + if (facet) targetSelector += `[${facet.attribute}="${option}"]`; + }); + const ancestors: string[] = []; + stateIds.forEach(stateId => { + const state = descriptor.states?.find(candidate => candidate.id === stateId); + if (!state) return; + if (state.selector.kind === 'self' || state.selector.part === partId) { + targetSelector += state.selector.suffix; + } else { + ancestors.push( + `[${surfaceAttribute}="${surfaceId}"][data-bf-part="${state.selector.part}"]${state.selector.suffix}`, + ); + } + }); + return `${rootSelector} ${[...ancestors, targetSelector].join(' ')}`; + }; + const materialStyle = (rule.materials ?? []).reduce( + (combined, materialId) => ({ ...combined, ...(materials[materialId] ?? {}) }), + {}, + ); + const base = { ...materialStyle, ...this.serializeStyle(rule.base ?? {}, context) }; + if (Object.keys(base).length > 0) { + rules.push(compileRule(baseSelector, base)); + this.auditContrast(base, baseSelector, context); + } + + Object.entries(rule.facets ?? {}).forEach(([facetId, options]) => { + const facet = descriptor.facets?.find(candidate => candidate.id === facetId); + if (!facet) return; + Object.entries(options).forEach(([option, style]) => { + const resolved = this.serializeStyle(style, context); + rules.push(compileRule(selectorFor({ [facetId]: option }), resolved)); + this.auditContrast({ ...base, ...resolved }, `${baseSelector}.${facetId}.${option}`, context); + }); + }); + + Object.entries(rule.states ?? {}).forEach(([stateId, style]) => { + const state = descriptor.states?.find(candidate => candidate.id === stateId); + if (!state) return; + const resolved = this.serializeStyle(style, context); + rules.push(compileRule(selectorFor({}, [stateId]), resolved)); + this.auditContrast({ ...base, ...resolved }, `${baseSelector}.${stateId}`, context); + }); + + (rule.contexts ?? []).forEach(contextRule => { + const selector = selectorFor(contextRule.when.facets, contextRule.when.states); + const resolved = this.serializeStyle(contextRule.style, context); + rules.push(compileRule(selector, resolved)); + this.auditContrast({ ...base, ...resolved }, selector, context); + }); + return rules; + } + + private serializeStyle(style: AppearanceStyle, context: CompileContext): ResolvedAppearanceStyle { + const resolved: ResolvedAppearanceStyle = {}; + (Object.entries(style) as [AppearanceStyleProperty, unknown][]).forEach(([property, value]) => { + resolved[property] = this.serializeProperty(property, value, context); + }); + return Object.freeze(resolved); + } + + private serializeProperty(property: AppearanceStyleProperty, value: unknown, context: CompileContext): string { + if (property === 'backgroundImage') { + return this.serializeAssetReference(value, context); + } + if (property === 'backgroundImages') { + return (value as NonNullable) + .map(asset => this.serializeAssetReference(asset, context)) + .join(', '); + } + if (property === 'backgroundSizes') { + return (value as NonNullable) + .map(size => { + if (typeof size === 'string') return size; + const height = size.height ? ` ${this.serializeValue(size.height, context)}` : ''; + return `${this.serializeValue(size.width, context)}${height}`; + }) + .join(', '); + } + if (property === 'backgroundPositions') { + return (value as NonNullable) + .map(position => typeof position === 'string' + ? position + : `${this.serializeValue(position.x, context)} ${this.serializeValue(position.y, context)}`) + .join(', '); + } + if (property === 'backgroundRepeats' || property === 'backgroundBlendModes') { + return (value as string[]).join(', '); + } + if (property === 'boxShadow') { + return (value as AppearanceShadowValue[]).map(shadow => this.serializeValue(shadow, context)).join(', '); + } + if (property === 'transition') { + return (value as AppearanceStyle['transition'] ?? []).map(item => { + const delay = item.delay ? ` ${this.serializeValue(item.delay, context)}` : ''; + return `${item.property} ${this.serializeValue(item.duration, context)} ${this.serializeValue(item.easing, context)}${delay}`; + }).join(', '); + } + if (property === 'transform') { + const transform = value as NonNullable; + const parts: string[] = []; + if (transform.translateX) parts.push(`translateX(${this.serializeValue(transform.translateX, context)})`); + if (transform.translateY) parts.push(`translateY(${this.serializeValue(transform.translateY, context)})`); + if (transform.rotate !== undefined) parts.push(`rotate(${transform.rotate}deg)`); + if (transform.scale !== undefined) parts.push(`scale(${transform.scale})`); + return parts.length > 0 ? parts.join(' ') : 'none'; + } + if (property === 'gridTemplateColumns' || property === 'gridTemplateRows') { + const template = value as NonNullable; + return template.tracks.map(track => this.serializeValue(track, context)).join(' '); + } + if (property === 'gridColumnSpan' || property === 'gridRowSpan') { + return `span ${this.serializeValue(value, context)}`; + } + if (property === 'aspectRatio') { + const ratio = value as NonNullable; + return `${ratio.width} / ${ratio.height}`; + } + if (property === 'filter') { + const filters = value as NonNullable; + if (filters.length === 0) return 'none'; + return filters.map(filter => { + if (filter.kind === 'blur') return `blur(${this.serializeValue(filter.value, context)})`; + if (filter.kind === 'hueRotate') return `hue-rotate(${filter.degrees}deg)`; + return `${filter.kind}(${this.serializeValue(filter.value, context)})`; + }).join(' '); + } + if (property === 'backdropBlur') { + return `blur(${this.serializeValue(value, context)})`; + } + return this.serializeValue(value, context); + } + + private serializeValue(value: unknown, context: CompileContext): string { + if (typeof value === 'string') return value; + if (!value || typeof value !== 'object') return String(value); + const record = value as Record; + switch (record.kind) { + case 'ref': + return this.resolveCssReference((record as unknown as AppearanceReference).path, context); + case 'hex': + return String(record.value).toLowerCase(); + case 'rgb': { + const alpha = record.a === undefined ? '' : ` / ${record.a}`; + return `rgb(${record.r} ${record.g} ${record.b}${alpha})`; + } + case 'hsl': { + const alpha = record.a === undefined ? '' : ` / ${record.a}`; + return `hsl(${record.h} ${record.s}% ${record.l}%${alpha})`; + } + case 'transparent': + return 'transparent'; + case 'px': + return `${record.value}px`; + case 'rem': + return `${record.value}rem`; + case 'em': + return `${record.value}em`; + case 'ch': + return `${record.value}ch`; + case 'vw': + return `${record.value}vw`; + case 'vh': + return `${record.value}vh`; + case 'percent': + return `${record.value}%`; + case 'lengthKeyword': + return String(record.value); + case 'fr': + return `${record.value}fr`; + case 'trackKeyword': + return String(record.value); + case 'zero': + return '0'; + case 'number': + return String(record.value); + case 'ms': + return `${record.value}ms`; + case 'easing': + return this.serializeEasing(value as AppearanceEasingValue); + case 'fontFamily': + return (record.families as string[]).map(family => `'${family}'`).join(', '); + case 'none': + return 'none'; + case 'shadow': { + const shadow = value as AppearanceShadowValue & Record; + if (shadow.kind !== 'shadow') return 'none'; + const spread = shadow.spread ? ` ${this.serializeValue(shadow.spread, context)}` : ''; + return `${shadow.inset ? 'inset ' : ''}${this.serializeValue(shadow.x, context)} ${this.serializeValue(shadow.y, context)} ${this.serializeValue(shadow.blur, context)}${spread} ${this.serializeValue(shadow.color, context)}`; + } + default: + throw new Error(`Unsupported structured appearance value: ${String(record.kind)}`); + } + } + + private serializeEasing(value: AppearanceEasingValue): string { + if (value.kind === 'ref') return this.referenceToVariable(value.path); + const easings = { + linear: 'linear', + standard: 'cubic-bezier(0.2, 0, 0, 1)', + decelerate: 'cubic-bezier(0, 0, 0, 1)', + accelerate: 'cubic-bezier(0.3, 0, 1, 1)', + } as const; + return easings[value.value]; + } + + private resolveCssReference(path: string, context: CompileContext): string { + if (!context.globals.has(path)) { + throw new Error(`Unknown appearance token reference: ${path}`); + } + return `var(${this.referenceToVariable(path)})`; + } + + private referenceToVariable(path: string): string { + return `--bf-appearance-${path.replace(/^globals\./, '').replace(/\./g, '-')}`; + } + + private assetVariable(assetId: string): string { + return `--bf-appearance-asset-${assetId.replace(/\./g, '-')}`; + } + + private serializeAssetReference(value: unknown, context: CompileContext): string { + const assetId = (value as { assetId: string }).assetId; + if (!(assetId in (context.pkg.assets ?? {}))) { + throw new Error(`Unknown appearance asset: ${assetId}`); + } + return `var(${this.assetVariable(assetId)}, none)`; + } + + private resolveConcreteReference(path: string, context: CompileContext, stack: Set): string { + const cached = context.concreteGlobals.get(path); + if (cached !== undefined) return cached; + if (stack.has(path)) throw new Error(`Circular appearance token reference: ${path}`); + const value = context.globals.get(path); + if (value === undefined) throw new Error(`Unknown appearance token reference: ${path}`); + stack.add(path); + let concrete: string; + if (this.isReference(value)) { + concrete = this.resolveConcreteReference(value.path, context, stack); + } else { + concrete = this.serializeValue(value, context); + } + stack.delete(path); + context.concreteGlobals.set(path, concrete); + return concrete; + } + + private isReference(value: unknown): value is AppearanceReference { + return Boolean(value && typeof value === 'object' && (value as { kind?: unknown }).kind === 'ref'); + } + + private renderRule(rule: CompiledRule): string { + const declarations = (Object.keys(CSS_PROPERTIES) as AppearanceStyleProperty[]) + .filter(property => rule.style[property] !== undefined) + .map(property => { + const priority = rule.important && rule.forceableProperties.has(property) ? ' !important' : ''; + return `${CSS_PROPERTIES[property]}:${rule.style[property]}${priority};`; + }) + .join(''); + return declarations ? `${rule.selector}{${declarations}}` : ''; + } + + private normalizeResolvedStyle(style: ResolvedAppearanceStyle): ResolvedAppearanceStyle { + const needsBorderReset = style.borderStyle !== undefined && style.borderWidth === undefined; + const needsOutlineStyle = (style.outlineWidth !== undefined || style.outlineColor !== undefined) + && style.outlineStyle === undefined; + if (!needsBorderReset && !needsOutlineStyle) return Object.freeze({ ...style }); + return Object.freeze({ + ...(needsBorderReset ? { borderWidth: '0' } : {}), + ...style, + ...(needsOutlineStyle ? { outlineStyle: 'solid' } : {}), + }); + } + + private auditContrast(style: ResolvedAppearanceStyle, path: string, context: CompileContext): void { + if (!style.color || !style.backgroundColor) return; + const foreground = this.resolveConcreteCssValue(style.color, context); + const background = this.resolveConcreteCssValue(style.backgroundColor, context); + const ratio = this.contrastRatio(foreground, background); + if (ratio !== null && ratio < 3) { + context.diagnostics.push({ + level: 'warning', + code: 'LOW_CONTRAST', + path, + message: `Foreground/background contrast is ${ratio.toFixed(2)}:1`, + }); + } + } + + private resolveConcreteCssValue(value: string, context: CompileContext): string { + const match = /^var\((--bf-appearance-[^)]+)\)$/.exec(value); + if (!match) return value; + const path = [...context.globals.keys()].find(candidate => this.referenceToVariable(candidate) === match[1]); + return path ? this.resolveConcreteReference(path, context, new Set()) : value; + } + + private contrastRatio(foreground: string, background: string): number | null { + const fg = this.parseColor(foreground); + const bg = this.parseColor(background); + if (!fg || !bg || fg[3] < 1 || bg[3] < 1) return null; + const fgLum = this.relativeLuminance(fg); + const bgLum = this.relativeLuminance(bg); + return (Math.max(fgLum, bgLum) + 0.05) / (Math.min(fgLum, bgLum) + 0.05); + } + + private parseColor(value: string): [number, number, number, number] | null { + const hex = /^#([0-9a-f]{3,8})$/i.exec(value); + if (hex) { + const raw = hex[1]; + const expanded = raw.length <= 4 ? raw.split('').map(char => char + char).join('') : raw; + const alpha = expanded.length === 8 ? parseInt(expanded.slice(6, 8), 16) / 255 : 1; + return [parseInt(expanded.slice(0, 2), 16), parseInt(expanded.slice(2, 4), 16), parseInt(expanded.slice(4, 6), 16), alpha]; + } + const rgb = /^rgb\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/.exec(value); + if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3]), rgb[4] === undefined ? 1 : Number(rgb[4])]; + const hsl = /^hsl\(\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+))?\s*\)$/.exec(value); + if (!hsl) return null; + const h = Number(hsl[1]) / 360; + const s = Number(hsl[2]) / 100; + const l = Number(hsl[3]) / 100; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const channel = (offset: number) => { + let t = h + offset; + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + return [channel(1 / 3) * 255, channel(0) * 255, channel(-1 / 3) * 255, hsl[4] === undefined ? 1 : Number(hsl[4])]; + } + + private relativeLuminance(color: [number, number, number, number]): number { + const channels = color.slice(0, 3).map(channel => { + const normalized = channel / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + } +} diff --git a/src/web-ui/src/infrastructure/appearance/hooks/useAppearance.ts b/src/web-ui/src/infrastructure/appearance/hooks/useAppearance.ts new file mode 100644 index 0000000000..9bd1bd5cac --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/hooks/useAppearance.ts @@ -0,0 +1,23 @@ +import { useSyncExternalStore } from 'react'; +import { appearanceService } from '../index'; + +const getPreviewAsset = (id: string) => appearanceService.getPreviewAsset(id); + +export function useAppearance() { + const snapshot = useSyncExternalStore( + listener => appearanceService.subscribe(listener), + () => appearanceService.getSnapshot(), + () => appearanceService.getSnapshot(), + ); + return { + ...snapshot, + select: (id: string) => appearanceService.select(id), + getPackage: (id: string) => appearanceService.getPackage(id), + getPreviewAsset, + importPackage: (source: ArrayBuffer) => appearanceService.importPackage(source), + exportPackage: (id: string) => appearanceService.exportPackage(id), + activate: (id: string) => appearanceService.activate(id), + deactivate: () => appearanceService.deactivate(), + deletePackage: (id: string) => appearanceService.deletePackage(id), + }; +} diff --git a/src/web-ui/src/infrastructure/appearance/index.ts b/src/web-ui/src/infrastructure/appearance/index.ts new file mode 100644 index 0000000000..568222e33b --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/index.ts @@ -0,0 +1,48 @@ +import { createDefaultAppearanceRegistry } from './registry/defaultAppearanceRegistry'; +import { AppearancePackageParser } from './schema/AppearancePackageParser'; +import { createAppearanceStorage } from './storage/AppearanceStorage'; +import { AppearanceRuntime } from './runtime/AppearanceRuntime'; +import { AppearanceService } from './runtime/AppearanceService'; +import { createAppearanceSyncPort } from './sync/AppearanceSync'; + +export * from './types'; +export * from './schema/AppearancePackageValidator'; +export * from './schema/AppearancePackageValidationError'; +export * from './schema/AppearancePackageParser'; +export * from './compiler/AppearanceCompiler'; +export * from './runtime/AppearanceRuntime'; +export * from './runtime/AppearanceService'; +export * from './runtime/AppearanceOverlayHost'; +export * from './runtime/AppearanceBackgroundMedia'; +export * from './registry/AppearanceRegistry'; +export * from './registry/defaultAppearanceRegistry'; +export * from './storage/AppearanceStorage'; +export * from './sync/AppearanceSync'; +export * from './builtins/buildBuiltinAppearance'; +export * from './builtins/catalog'; +export * from './builtins/AppearancePalette'; +export * from './builtins/palettes'; +export * from './builtins/startupAppearanceBootstrap'; +export * from './builtins/appearancePromptSnapshots'; +export * from './adapters/MonacoAppearanceAdapter'; +export * from './adapters/XtermAppearanceAdapter'; +export * from './appearanceDomainTokens'; +export * from './appearancePropertyProfiles'; +export * from './adapters/MermaidAppearanceAdapter'; +export * from './adapters/WidgetAppearanceAdapter'; +export * from './adapters/CanvasAppearanceAdapter'; +export * from './adapters/CssTokenAppearanceAdapter'; +export * from './adapters/PluginAppearanceProjection'; +export * from './hooks/useAppearance'; + +export const appearanceRegistry = createDefaultAppearanceRegistry(); +export const appearanceRuntime = new AppearanceRuntime(appearanceRegistry); +export const appearanceStorage = createAppearanceStorage(); +export const appearancePackageParser = new AppearancePackageParser(appearanceRegistry); +export const appearanceSyncPort = createAppearanceSyncPort(); +export const appearanceService = new AppearanceService( + appearanceRuntime, + appearancePackageParser, + appearanceStorage, + appearanceSyncPort, +); diff --git a/src/web-ui/src/infrastructure/appearance/registry/AppearanceRegistry.test.ts b/src/web-ui/src/infrastructure/appearance/registry/AppearanceRegistry.test.ts new file mode 100644 index 0000000000..111b07d308 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/registry/AppearanceRegistry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { AppearanceRegistry } from './AppearanceRegistry'; + +describe('AppearanceRegistry', () => { + it('deep-freezes registered host surface contracts', () => { + const descriptor = { + id: 'test-surface', + parts: [{ id: 'root', allowedProperties: ['color'] as const }], + facets: [{ id: 'size', attribute: 'data-bf-size' as const, values: ['small', 'large'] }], + states: [{ id: 'active', selector: { kind: 'self' as const, suffix: '[data-bf-state~="active"]' } }], + }; + const registry = new AppearanceRegistry().registerComponent(descriptor).freeze(); + const registered = registry.getComponent('test-surface')!; + + expect(Object.isFrozen(registered)).toBe(true); + expect(Object.isFrozen(registered.parts)).toBe(true); + expect(Object.isFrozen(registered.parts[0])).toBe(true); + expect(Object.isFrozen(registered.parts[0].allowedProperties)).toBe(true); + expect(Object.isFrozen(registered.parts[0].forceableProperties)).toBe(true); + expect(Object.isFrozen(registered.facets?.[0].values)).toBe(true); + }); + + it('applies safe property defaults and rejects forceable properties outside the part contract', () => { + const registry = new AppearanceRegistry().registerComponent({ + id: 'default-contract', + parts: [{ id: 'root' }], + }); + const part = registry.getComponent('default-contract')!.parts[0]; + expect(part.propertyProfile).toBe('container'); + expect(part.forceableProperties).toContain('backgroundColor'); + expect(part.forceableProperties).not.toContain('position'); + + expect(() => new AppearanceRegistry().registerComponent({ + id: 'invalid-forceable-contract', + parts: [{ + id: 'root', + allowedProperties: ['color'], + forceableProperties: ['position'], + }], + })).toThrow('forces a property outside its allowed contract'); + }); + + it('rejects duplicate ids and malformed host selectors', () => { + expect(() => new AppearanceRegistry().registerComponent({ + id: 'duplicate-parts', + parts: [{ id: 'root' }, { id: 'root' }], + })).toThrow('duplicate part ids'); + + const registry = new AppearanceRegistry().registerComponent({ + id: 'shared-id', + parts: [{ id: 'root' }], + }); + expect(() => registry.registerScene({ + id: 'shared-id', + parts: [{ id: 'root' }], + })).toThrow('Duplicate appearance surface id'); + + expect(() => new AppearanceRegistry().registerComponent({ + id: 'unsafe-state', + parts: [{ id: 'root' }], + states: [{ id: 'unsafe', selector: { kind: 'self', suffix: ' body *' } }], + })).toThrow('Invalid appearance state'); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/registry/AppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/AppearanceRegistry.ts new file mode 100644 index 0000000000..67a6ad4d5a --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/registry/AppearanceRegistry.ts @@ -0,0 +1,169 @@ +import type { + AppearanceRendererId, + AppearanceRendererAdapter, + AppearanceSurfaceDescriptor, + RegisteredAppearanceRendererAdapter, +} from '../types'; +import { + DEFAULT_APPEARANCE_PROPERTY_PROFILE, + getAppearanceProfileProperties, + getDefaultForceableAppearanceProperties, +} from '../appearancePropertyProfiles'; + +const SURFACE_ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/; +const CONTRACT_ID_PATTERN = /^[a-z][a-zA-Z0-9]*$/; +const FACET_ATTRIBUTE_PATTERN = /^data-bf-[a-z][a-z0-9-]*$/; +const STATE_SUFFIX_PATTERN = /^(?::(?:hover|active|focus-visible|focus-within|disabled)(?::not\(:disabled\))?|:has\(input:checked\)|\[data-bf-state~="[a-z][a-zA-Z0-9-]*"\])$/; + +export class AppearanceRegistry { + private readonly components = new Map(); + private readonly scenes = new Map(); + private readonly renderers = new Map(); + private frozen = false; + + registerComponent(descriptor: AppearanceSurfaceDescriptor): this { + this.assertMutable(); + this.assertSurfaceIdAvailable(descriptor.id, 'component'); + this.registerUnique(this.components, descriptor.id, this.prepareDescriptor(descriptor), 'component'); + return this; + } + + registerScene(descriptor: AppearanceSurfaceDescriptor): this { + this.assertMutable(); + this.assertSurfaceIdAvailable(descriptor.id, 'scene'); + this.registerUnique(this.scenes, descriptor.id, this.prepareDescriptor(descriptor), 'scene'); + return this; + } + + registerRenderer(adapter: AppearanceRendererAdapter): this { + this.assertMutable(); + this.registerUnique( + this.renderers, + adapter.id, + adapter as RegisteredAppearanceRendererAdapter, + 'renderer', + ); + return this; + } + + getComponent(id: string): AppearanceSurfaceDescriptor | undefined { + return this.components.get(id); + } + + getScene(id: string): AppearanceSurfaceDescriptor | undefined { + return this.scenes.get(id); + } + + getRenderer(id: string): RegisteredAppearanceRendererAdapter | undefined { + return this.renderers.get(id as AppearanceRendererId); + } + + getRendererAdapters(): readonly RegisteredAppearanceRendererAdapter[] { + return [...this.renderers.values()]; + } + + getComponents(): readonly AppearanceSurfaceDescriptor[] { + return [...this.components.values()]; + } + + getScenes(): readonly AppearanceSurfaceDescriptor[] { + return [...this.scenes.values()]; + } + + freeze(): this { + this.frozen = true; + return this; + } + + isFrozen(): boolean { + return this.frozen; + } + + private assertMutable(): void { + if (this.frozen) { + throw new Error('Appearance registry is frozen'); + } + } + + private assertSurfaceIdAvailable(id: string, kind: 'component' | 'scene'): void { + if (this.components.has(id) || this.scenes.has(id)) { + throw new Error(`Duplicate appearance surface id: ${id} (${kind})`); + } + } + + private prepareDescriptor(descriptor: AppearanceSurfaceDescriptor): AppearanceSurfaceDescriptor { + if (!SURFACE_ID_PATTERN.test(descriptor.id)) { + throw new Error(`Invalid appearance surface id: ${descriptor.id}`); + } + if (descriptor.parts.length === 0) { + throw new Error(`Appearance surface ${descriptor.id} must declare at least one part`); + } + this.assertUniqueIds(descriptor.id, 'part', descriptor.parts.map(part => part.id)); + this.assertUniqueIds(descriptor.id, 'facet', (descriptor.facets ?? []).map(facet => facet.id)); + this.assertUniqueIds(descriptor.id, 'state', (descriptor.states ?? []).map(state => state.id)); + + const parts = descriptor.parts.map(part => { + if (!CONTRACT_ID_PATTERN.test(part.id)) { + throw new Error(`Invalid appearance part id: ${descriptor.id}.${part.id}`); + } + const propertyProfile = part.propertyProfile ?? DEFAULT_APPEARANCE_PROPERTY_PROFILE; + const allowedProperties = part.allowedProperties ?? getAppearanceProfileProperties(propertyProfile); + const allowedPropertySet = new Set(allowedProperties); + const requestedForceableProperties = part.forceableProperties ?? getDefaultForceableAppearanceProperties(); + if (part.forceableProperties?.some(property => !allowedPropertySet.has(property))) { + throw new Error(`Appearance part ${descriptor.id}.${part.id} forces a property outside its allowed contract`); + } + const forceableProperties = [...new Set(requestedForceableProperties)] + .filter(property => allowedPropertySet.has(property)); + return Object.freeze({ + ...part, + propertyProfile, + allowedProperties: part.allowedProperties + ? Object.freeze([...part.allowedProperties]) + : undefined, + forceableProperties: Object.freeze(forceableProperties), + }); + }); + const facets = (descriptor.facets ?? []).map(facet => { + if (!CONTRACT_ID_PATTERN.test(facet.id) || !FACET_ATTRIBUTE_PATTERN.test(facet.attribute)) { + throw new Error(`Invalid appearance facet: ${descriptor.id}.${facet.id}`); + } + if (facet.values.length === 0 || new Set(facet.values).size !== facet.values.length) { + throw new Error(`Appearance facet ${descriptor.id}.${facet.id} must declare unique values`); + } + return Object.freeze({ ...facet, values: Object.freeze([...facet.values]) }); + }); + const states = (descriptor.states ?? []).map(state => { + const selector = state.selector; + if (!CONTRACT_ID_PATTERN.test(state.id) + || !selector + || !STATE_SUFFIX_PATTERN.test(selector.suffix) + || (selector.kind !== 'self' && selector.kind !== 'ancestorPart') + || (selector.kind === 'ancestorPart' + && !parts.some(part => part.id === selector.part))) { + throw new Error(`Invalid appearance state: ${descriptor.id}.${state.id}`); + } + return Object.freeze({ ...state, selector: Object.freeze({ ...selector }) }); + }); + + return Object.freeze({ + ...descriptor, + parts: Object.freeze(parts), + facets: facets.length > 0 ? Object.freeze(facets) : undefined, + states: states.length > 0 ? Object.freeze(states) : undefined, + }); + } + + private assertUniqueIds(surfaceId: string, kind: string, ids: readonly string[]): void { + if (new Set(ids).size !== ids.length) { + throw new Error(`Appearance surface ${surfaceId} contains duplicate ${kind} ids`); + } + } + + private registerUnique(map: Map, id: string, value: T, kind: string): void { + if (map.has(id)) { + throw new Error(`Duplicate appearance ${kind} id: ${id}`); + } + map.set(id, value); + } +} diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts new file mode 100644 index 0000000000..be43be05f7 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -0,0 +1,551 @@ +import { buttonAppearanceDescriptor } from '@/component-library/components/Button/appearance'; +import { cardAppearanceDescriptor } from '@/component-library/components/Card/appearance'; +import { inputAppearanceDescriptor } from '@/component-library/components/Input/appearance'; +import { modalAppearanceDescriptor } from '@/component-library/components/Modal/appearance'; +import { tooltipAppearanceDescriptor } from '@/component-library/components/Tooltip/appearance'; +import { iconButtonAppearanceDescriptor } from '@/component-library/components/IconButton/appearance'; +import { checkboxAppearanceDescriptor } from '@/component-library/components/Checkbox/appearance'; +import { switchAppearanceDescriptor } from '@/component-library/components/Switch/appearance'; +import { textareaAppearanceDescriptor } from '@/component-library/components/Textarea/appearance'; +import { numberInputAppearanceDescriptor } from '@/component-library/components/NumberInput/appearance'; +import { searchAppearanceDescriptor } from '@/component-library/components/Search/appearance'; +import { tabsAppearanceDescriptor } from '@/component-library/components/Tabs/appearance'; +import { selectAppearanceDescriptor } from '@/component-library/components/Select/appearance'; +import { alertAppearanceDescriptor } from '@/component-library/components/Alert/appearance'; +import { badgeAppearanceDescriptor } from '@/component-library/components/Badge/appearance'; +import { tagAppearanceDescriptor } from '@/component-library/components/Tag/appearance'; +import { avatarAppearanceDescriptor } from '@/component-library/components/Avatar/appearance'; +import { codeEditorAppearanceDescriptor } from '@/component-library/components/CodeEditor/appearance'; +import { configPageAppearanceDescriptor } from '@/component-library/components/ConfigPage/appearance'; +import { confirmDialogAppearanceDescriptor } from '@/component-library/components/ConfirmDialog/appearance'; +import { cubeLoadingAppearanceDescriptor } from '@/component-library/components/CubeLoading/appearance'; +import { cubeLogoAppearanceDescriptor } from '@/component-library/components/CubeLogo/appearance'; +import { dotMatrixLoaderAppearanceDescriptor } from '@/component-library/components/DotMatrixLoader/appearance'; +import { emptyAppearanceDescriptor } from '@/component-library/components/Empty/appearance'; +import { filterPillAppearanceDescriptor } from '@/component-library/components/FilterPill/appearance'; +import { inputDialogAppearanceDescriptor } from '@/component-library/components/InputDialog/appearance'; +import { streamTextAppearanceDescriptor } from '@/component-library/components/StreamText/appearance'; +import { taskRunningIndicatorAppearanceDescriptor } from '@/component-library/components/TaskRunningIndicator/appearance'; +import { textStrokeEffectAppearanceDescriptor } from '@/component-library/components/TextStrokeEffect/appearance'; +import { windowControlsAppearanceDescriptor } from '@/component-library/components/WindowControls/appearance'; +import { markdownAppearanceDescriptor } from '@/component-library/components/Markdown/appearance'; +import { flowChatCardAppearanceDescriptor } from '@/component-library/components/FlowChatCards/appearance'; +import { chatInputAppearanceDescriptor } from '@/flow_chat/components/ChatInput.appearance'; +import { composerVoiceInputAppearanceDescriptor } from '@/flow_chat/components/voice/ComposerVoiceInputButton.appearance'; +import { sessionUsagePanelAppearanceDescriptor } from '@/flow_chat/components/usage/appearance'; +import { fileOperationToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/FileOperationToolCard.appearance'; +import { richTextInputAppearanceDescriptor } from '@/flow_chat/components/RichTextInput.appearance'; +import { modelRoundItemAppearanceDescriptor } from '@/flow_chat/components/modern/ModelRoundItem.appearance'; +import { deepReviewActionBarAppearanceDescriptor } from '@/flow_chat/deep-review/action-bar/appearance'; +import { modelSelectorAppearanceDescriptor } from '@/flow_chat/components/ModelSelector.appearance'; +import { flowChatHeaderAppearanceDescriptor } from '@/flow_chat/components/modern/FlowChatHeader.appearance'; +import { flowChatTurnRailAppearanceDescriptor } from '@/flow_chat/components/modern/FlowChatTurnRail.appearance'; +import { sessionFilesBadgeAppearanceDescriptor } from '@/flow_chat/components/modern/SessionFilesBadge.appearance'; +import { codeReviewToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/CodeReviewToolCard.appearance'; +import { createAgentPageAppearanceDescriptor } from '@/app/scenes/agents/components/CreateAgentPage.appearance'; +import { keyboardShortcutsAppearanceDescriptor } from '@/app/scenes/settings/components/KeyboardShortcutsTab.appearance'; +import { taskToolDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/TaskToolDisplay.appearance'; +import { basicsConfigAppearanceDescriptor } from '@/infrastructure/config/components/BasicsConfig.appearance'; +import { markdownEditorAppearanceDescriptor } from '@/tools/editor/components/MarkdownEditor.appearance'; +import { planViewerAppearanceDescriptor } from '@/tools/editor/components/PlanViewer.appearance'; +import { appLayoutAppearanceDescriptor } from '@/app/layout/AppLayout.appearance'; +import { skillGroupPickerAppearanceDescriptor } from '@/app/scenes/agents/components/SkillGroupPicker.appearance'; +import { workingCopyViewAppearanceDescriptor } from '@/app/scenes/git/views/WorkingCopyView.appearance'; +import { assistantConfigPageAppearanceDescriptor } from '@/app/scenes/profile/views/AssistantConfigPage.appearance'; +import { assistantDefaultsPageAppearanceDescriptor } from '@/app/scenes/profile/views/AssistantDefaultsPage.appearance'; +import { taskDetailPanelAppearanceDescriptor } from '@/flow_chat/components/TaskDetailPanel/TaskDetailPanel.appearance'; +import { toolbarModeAppearanceDescriptor } from '@/flow_chat/components/toolbar-mode/ToolbarMode.appearance'; +import { mcpToolDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/MCPToolDisplay.appearance'; +import { terminalToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/TerminalToolCard.appearance'; +import { skillsConfigAppearanceDescriptor } from '@/infrastructure/config/components/SkillsConfig.appearance'; +import { diffEditorAppearanceDescriptor } from '@/tools/editor/components/DiffEditor.appearance'; +import { agentCompanionDesktopPetAppearanceDescriptor } from '@/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.appearance'; +import { toolGroupPickerAppearanceDescriptor } from '@/app/scenes/agents/components/ToolGroupPicker.appearance'; +import { inlineDiffPreviewAppearanceDescriptor } from '@/flow_chat/components/InlineDiffPreview.appearance'; +import { exportImageAppearanceDescriptor } from '@/flow_chat/components/modern/ExportImageButton.appearance'; +import { userMessageItemAppearanceDescriptor } from '@/flow_chat/components/modern/UserMessageItem.appearance'; +import { sessionUsageReportCardAppearanceDescriptor } from '@/flow_chat/components/usage/SessionUsageReportCard.appearance'; +import { askUserQuestionCardAppearanceDescriptor } from '@/flow_chat/tool-cards/AskUserQuestionCard.appearance'; +import { createPlanDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/CreatePlanDisplay.appearance'; +import { execProcessToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/ExecProcessToolCardView.appearance'; +import { editorConfigAppearanceDescriptor } from '@/infrastructure/config/components/EditorConfig.appearance'; +import { appearanceConfigAppearanceDescriptor } from '@/infrastructure/config/components/AppearanceConfig.appearance'; +import { lspConfigAppearanceDescriptor } from '@/infrastructure/config/components/LspConfig.appearance'; +import { configAppearanceDescriptor } from '@/infrastructure/config/appearance'; +import { fontPreferenceAppearanceDescriptor } from '@/infrastructure/font-preference/appearance'; +import { languageSelectorAppearanceDescriptor } from '@/infrastructure/i18n/appearance'; +import { peerDeviceAppearanceDescriptor } from '@/infrastructure/peer-device/appearance'; +import { updateAppearanceDescriptor } from '@/infrastructure/update/appearance'; +import { componentPreviewAppearanceDescriptor } from '@/component-library/preview/appearance'; +import { announcementAppearanceDescriptor } from '@/shared/announcement-system/appearance'; +import { contextMenuAppearanceDescriptor } from '@/shared/context-menu-system/appearance'; +import { contextListAppearanceDescriptor } from '@/shared/context-system/appearance'; +import { notificationAppearanceDescriptor } from '@/shared/notification-system/appearance'; +import { canvasToolAppearanceDescriptor } from '@/tools/bitfun-canvas/appearance'; +import { generativeWidgetAppearanceDescriptor } from '@/tools/generative-widget/appearance'; +import { editorToolAppearanceDescriptor } from '@/tools/editor/appearance'; +import { fileSystemAppearanceDescriptor } from '@/tools/file-system/appearance'; +import { gitToolAppearanceDescriptor } from '@/tools/git/appearance'; +import { lspAppearanceDescriptor } from '@/tools/lsp/appearance'; +import { terminalToolAppearanceDescriptor } from '@/tools/terminal/appearance'; +import { workspaceToolAppearanceDescriptor } from '@/tools/workspace/appearance'; +import { relayDeployAppearanceDescriptor } from '@/features/relay-deploy/appearance'; +import { sshRemoteAppearanceDescriptor } from '@/features/ssh-remote/appearance'; +import { workbenchAppearanceDescriptor } from '@/app/appearance'; +import { welcomeAppearanceDescriptor } from '@/app/scenes/welcome/appearance'; +import { shellAppearanceDescriptor } from '@/app/scenes/shell/appearance'; +import { agentsAppearanceDescriptor } from '@/app/scenes/agents/appearance'; +import { assistantAppearanceDescriptor } from '@/app/scenes/assistant/appearance'; +import { browserAppearanceDescriptor } from '@/app/scenes/browser/appearance'; +import { fileViewerAppearanceDescriptor } from '@/app/scenes/file-viewer/appearance'; +import { gitAppearanceDescriptor } from '@/app/scenes/git/appearance'; +import { miniAppAppearanceDescriptor, miniAppGalleryAppearanceDescriptor } from '@/app/scenes/miniapps/appearance'; +import { insightsAppearanceDescriptor } from '@/app/scenes/my-agent/appearance'; +import { pagesAppearanceDescriptor } from '@/app/scenes/pages/appearance'; +import { panelViewAppearanceDescriptor } from '@/app/scenes/panel-view/appearance'; +import { profileAppearanceDescriptor } from '@/app/scenes/profile/appearance'; +import { sessionAppearanceDescriptor } from '@/app/scenes/session/appearance'; +import { settingsAppearanceDescriptor } from '@/app/scenes/settings/appearance'; +import { skillsAppearanceDescriptor } from '@/app/scenes/skills/appearance'; +import { terminalAppearanceDescriptor } from '@/app/scenes/terminal/appearance'; +import { aboutDialogAppearanceDescriptor } from '@/app/components/AboutDialog/appearance'; +import { navPanelAppearanceDescriptor } from '@/app/components/NavPanel/appearance'; +import { sessionsSectionAppearanceDescriptor } from '@/app/components/NavPanel/sections/sessions/appearance'; +import { contentCanvasAppearanceDescriptor } from '@/app/components/panels/content-canvas/appearance'; +import { filesPanelAppearanceDescriptor } from '@/app/components/panels/FilesPanel.appearance'; +import { reviewPlatformAppearanceDescriptor } from '@/app/components/panels/review-platform/appearance'; +import { remoteAccountPanelAppearanceDescriptor, remoteConnectDialogAppearanceDescriptor } from '@/app/components/RemoteConnectDialog/appearance'; +import { scheduledJobsViewAppearanceDescriptor } from '@/app/components/scheduled-jobs/appearance'; +import { flexiblePanelAppearanceDescriptor } from '@/app/components/panels/base/FlexiblePanel.appearance'; +import { btwSessionPanelAppearanceDescriptor } from '@/flow_chat/components/btw/BtwSessionPanel.appearance'; +import { modernFlowChatAppearanceDescriptor, virtualMessageListAppearanceDescriptor } from '@/flow_chat/components/modern/appearance'; +import { aiModelConfigAppearanceDescriptor } from '@/infrastructure/config/components/AIModelConfig.appearance'; +import { externalSourcesConfigAppearanceDescriptor } from '@/infrastructure/config/components/ExternalSourcesConfig.appearance'; +import { acpAgentsConfigAppearanceDescriptor } from '@/infrastructure/config/components/AcpAgentsConfig.appearance'; +import { sessionConfigAppearanceDescriptor } from '@/infrastructure/config/components/SessionConfig.appearance'; +import { sessionTitleConfigAppearanceDescriptor } from '@/infrastructure/config/components/SessionTitleConfig.appearance'; +import { mcpToolsConfigAppearanceDescriptor } from '@/infrastructure/config/components/McpToolsConfig.appearance'; +import { externalMcpOverviewAppearanceDescriptor } from '@/infrastructure/config/components/ExternalMcpOverview.appearance'; +import { voiceInputDiagnosticsAppearanceDescriptor } from '@/infrastructure/config/components/VoiceInputDiagnostics.appearance'; +import { assistantCardAppearanceDescriptor } from '@/app/scenes/profile/views/AssistantCard.appearance'; +import { miniAppCustomizePanelAppearanceDescriptor } from '@/app/scenes/miniapps/customization/MiniAppCustomizePanel.appearance'; +import { userMessageEditComposerAppearanceDescriptor } from '@/flow_chat/components/modern/UserMessageEditComposer.appearance'; +import { monacoAppearanceAdapter } from '../adapters/MonacoAppearanceAdapter'; +import { xtermAppearanceAdapter } from '../adapters/XtermAppearanceAdapter'; +import { mermaidAppearanceAdapter } from '../adapters/MermaidAppearanceAdapter'; +import { widgetAppearanceAdapter } from '../adapters/WidgetAppearanceAdapter'; +import { canvasAppearanceAdapter } from '../adapters/CanvasAppearanceAdapter'; +import { cssTokenAppearanceAdapter } from '../adapters/CssTokenAppearanceAdapter'; +import { tiptapEditorAppearanceDescriptor } from '@/tools/editor/meditor/components/TiptapEditor.appearance'; +import { workspaceProjectPermissionsDialogAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance'; +import { workspaceSessionBatchModalAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.appearance'; +import { archivedSessionsConfigAppearanceDescriptor } from '@/app/scenes/settings/components/ArchivedSessionsConfig.appearance'; +import { settingsNavAppearanceDescriptor } from '@/app/scenes/settings/SettingsNav.appearance'; +import { backgroundCommandOutputPanelAppearanceDescriptor } from '@/flow_chat/components/background-command/BackgroundCommandOutputPanel.appearance'; +import { chatInputPixelPetAppearanceDescriptor } from '@/flow_chat/components/ChatInputPixelPet.appearance'; +import { fileMentionPickerAppearanceDescriptor } from '@/flow_chat/components/FileMentionPicker.appearance'; +import { sessionFileModificationsBarAppearanceDescriptor } from '@/flow_chat/components/modern/SessionFileModificationsBar.appearance'; +import { editorBreadcrumbAppearanceDescriptor } from '@/tools/editor/components/EditorBreadcrumb.appearance'; +import { gitBranchHistoryAppearanceDescriptor } from '@/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.appearance'; +import { gitDiffViewAppearanceDescriptor } from '@/tools/git/components/GitDiffView/GitDiffView.appearance'; +import { gitSettingsViewAppearanceDescriptor } from '@/tools/git/components/GitSettingsView/GitSettingsView.appearance'; +import { quickActionsConfigAppearanceDescriptor } from '@/infrastructure/config/components/QuickActionsConfig.appearance'; +import { statusBarPopoversAppearanceDescriptor } from '@/tools/editor/components/StatusBarPopovers/StatusBarPopovers.appearance'; +import { mEditorAppearanceDescriptor } from '@/tools/editor/meditor/components/MEditor.appearance'; +import { referencesPanelAppearanceDescriptor } from '@/tools/lsp/components/ReferencesPanel/ReferencesPanel.appearance'; +import { navSearchDialogAppearanceDescriptor } from '@/app/components/NavPanel/NavSearchDialog.appearance'; +import { workspaceRelatedPathsDialogAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.appearance'; +import { branchSelectModalAppearanceDescriptor } from '@/app/components/panels/BranchSelectModal.appearance'; +import { floatingMiniChatAppearanceDescriptor } from '@/app/layout/FloatingMiniChat.appearance'; +import { miniAppBubbleWelcomeAppearanceDescriptor } from '@/app/layout/MiniAppBubbleWelcome.appearance'; +import { branchesViewAppearanceDescriptor } from '@/app/scenes/git/views/BranchesView.appearance'; +import { miniAppGalleryViewAppearanceDescriptor } from '@/app/scenes/miniapps/views/MiniAppGalleryView.appearance'; +import { miniAppMarketViewAppearanceDescriptor } from '@/app/scenes/miniapps/views/MiniAppMarketView.appearance'; +import { miniAppSubmissionsViewAppearanceDescriptor } from '@/app/scenes/miniapps/views/MiniAppSubmissionsView.appearance'; +import { shellNavAppearanceDescriptor } from '@/app/scenes/shell/ShellNav.appearance'; +import { missionControlAppearanceDescriptor } from '@/app/components/panels/content-canvas/mission-control/MissionControl.appearance'; +import { canvasTabAppearanceDescriptor } from '@/app/components/panels/content-canvas/tab-bar/Tab.appearance'; +import { canvasTabBarAppearanceDescriptor } from '@/app/components/panels/content-canvas/tab-bar/TabBar.appearance'; +import { chatInputWorkspaceStripAppearanceDescriptor } from '@/flow_chat/components/ChatInputWorkspaceStrip.appearance'; +import { codePreviewAppearanceDescriptor } from '@/flow_chat/components/CodePreview.appearance'; +import { exploreGroupAppearanceDescriptor } from '@/flow_chat/components/modern/ExploreGroupRenderer.appearance'; +import { pendingQueuePanelAppearanceDescriptor } from '@/flow_chat/components/PendingQueuePanel.appearance'; +import { subagentProjectionAppearanceDescriptor } from '@/flow_chat/components/subagent/SubagentProjectionView.appearance'; +import { threadGoalDialogsAppearanceDescriptor } from '@/flow_chat/components/thread-goal/ThreadGoalDialogs.appearance'; +import { welcomePanelAppearanceDescriptor } from '@/flow_chat/components/WelcomePanel.appearance'; +import { generativeWidgetToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance'; +import { gitToolDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/GitToolDisplay.appearance'; +import { snapshotFullscreenDiffViewerAppearanceDescriptor } from '@/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.appearance'; +import { mermaidBlockAppearanceDescriptor } from '@/component-library/components/Markdown/MermaidBlock.appearance'; +import { defaultModelConfigAppearanceDescriptor } from '@/infrastructure/config/components/DefaultModelConfig.appearance'; +import { globalPermissionRulesDialogAppearanceDescriptor } from '@/infrastructure/config/components/GlobalPermissionRulesDialog.appearance'; +import { mcpResourceBrowserAppearanceDescriptor } from '@/infrastructure/config/components/MCPResourceBrowser.appearance'; +import { editorStatusBarAppearanceDescriptor } from '@/tools/editor/components/EditorStatusBar.appearance'; +import { imageViewerAppearanceDescriptor } from '@/tools/editor/components/ImageViewer.appearance'; +import { branchQuickSwitchAppearanceDescriptor } from '@/app/components/NavPanel/components/BranchQuickSwitch.appearance'; +import { workspaceListSectionAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceListSection.appearance'; +import { workspaceItemAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance'; +import { newProjectDialogAppearanceDescriptor } from '@/app/components/NewProjectDialog/NewProjectDialog.appearance'; +import { canvasEditorAreaAppearanceDescriptor } from '@/app/components/panels/content-canvas/editor-area/EditorArea.appearance'; +import { canvasEditorGroupAppearanceDescriptor } from '@/app/components/panels/content-canvas/editor-area/EditorGroup.appearance'; +import { canvasThumbnailAppearanceDescriptor } from '@/app/components/panels/content-canvas/mission-control/ThumbnailCard.appearance'; +import { canvasTabOverflowAppearanceDescriptor } from '@/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.appearance'; +import { browserPanelAppearanceDescriptor } from '@/app/scenes/browser/BrowserPanel.appearance'; +import { nurseryGalleryAppearanceDescriptor } from '@/app/scenes/profile/views/NurseryGallery.appearance'; +import { gitGraphViewAppearanceDescriptor } from '@/app/scenes/git/views/GraphView.appearance'; +import { navBarAppearanceDescriptor } from '@/app/components/NavBar/NavBar.appearance'; +import { splashScreenAppearanceDescriptor } from '@/app/components/SplashScreen/SplashScreen.appearance'; +import { chatPaneAppearanceDescriptor } from '@/app/scenes/session/ChatPane.appearance'; +import { auxPaneAppearanceDescriptor } from '@/app/scenes/session/AuxPane.appearance'; +import { bottomTerminalPaneAppearanceDescriptor } from '@/app/scenes/session/BottomTerminalPane.appearance'; +import { scheduledJobsModalAppearanceDescriptor } from '@/app/components/scheduled-jobs/ScheduledJobsModal.appearance'; +import { sceneBarAppearanceDescriptor } from '@/app/components/SceneBar/SceneBar.appearance'; +import { panelHeaderAppearanceDescriptor } from '@/app/components/panels/base/PanelHeader.appearance'; +import { terminalEditModalAppearanceDescriptor } from '@/app/components/panels/TerminalEditModal.appearance'; +import { galleryLayoutAppearanceDescriptor } from '@/app/components/GalleryLayout/GalleryLayout.appearance'; +import { skillCardAppearanceDescriptor } from '@/app/scenes/skills/components/SkillCard.appearance'; +import { miniAppCardAppearanceDescriptor } from '@/app/scenes/miniapps/components/MiniAppCard.appearance'; +import { agentCardAppearanceDescriptor } from '@/app/scenes/agents/components/AgentCard.appearance'; +import { coreAgentCardAppearanceDescriptor } from '@/app/scenes/agents/components/CoreAgentCard.appearance'; +import { agentCapabilityTooltipAppearanceDescriptor } from '@/app/scenes/agents/components/AgentCapabilityTooltip.appearance'; +import { gitNavAppearanceDescriptor } from '@/app/scenes/git/GitNav.appearance'; +import { fileViewerNavAppearanceDescriptor } from '@/app/scenes/file-viewer/FileViewerNav.appearance'; +import { assistantQuickInputAppearanceDescriptor } from '@/app/scenes/profile/views/AssistantQuickInput.appearance'; +import { galleryDetailModalAppearanceDescriptor } from '@/app/components/GalleryLayout/GalleryDetailModal.appearance'; +import { mcpInteractionDialogAppearanceDescriptor } from '@/app/components/MCPInteractionDialog/MCPInteractionDialog.appearance'; +import { remoteConnectDisclaimerAppearanceDescriptor } from '@/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.appearance'; +import { diffFullscreenViewerAppearanceDescriptor } from '@/app/components/panels/DiffFullscreenViewer.appearance'; +import { notificationButtonAppearanceDescriptor } from '@/app/components/TitleBar/NotificationButton.appearance'; +import { contextCompressionCardAppearanceDescriptor } from '@/component-library/components/FlowChatCards/ContextCompressionCard/appearance'; +import { reviewSessionSummaryCardAppearanceDescriptor } from '@/flow_chat/tool-cards/ReviewSessionSummaryCard.appearance'; +import { deepReviewConsentDialogAppearanceDescriptor } from '@/flow_chat/components/DeepReviewConsentDialog.appearance'; +import { flowTextBlockAppearanceDescriptor } from '@/flow_chat/components/FlowTextBlock.appearance'; +import { permissionRequestPanelAppearanceDescriptor } from '@/flow_chat/components/modern/PermissionRequestPanel.appearance'; +import { userMessageAppearanceDescriptor } from '@/flow_chat/components/UserMessage.appearance'; +import { toolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/BaseToolCard.appearance'; +import { canvasToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/CanvasToolCard.appearance'; +import { computerUseToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/ComputerUseToolCard.appearance'; +import { defaultToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/DefaultToolCard.appearance'; +import { getFileDiffDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/GetFileDiffDisplay.appearance'; +import { miniAppToolDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/MiniAppToolDisplay.appearance'; +import { modelThinkingDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/ModelThinkingDisplay.appearance'; +import { todoWriteDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/TodoWriteDisplay.appearance'; +import { toolTimeoutIndicatorAppearanceDescriptor } from '@/flow_chat/tool-cards/ToolTimeoutIndicator.appearance'; +import { webFetchCardAppearanceDescriptor } from '@/flow_chat/tool-cards/WebFetchCard.appearance'; +import { compactToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/CompactToolCard.appearance'; +import { toolCardStatusSlotAppearanceDescriptor } from '@/flow_chat/tool-cards/ToolCardStatusSlot.appearance'; +import { toolCardHeaderActionsAppearanceDescriptor } from '@/flow_chat/tool-cards/ToolCardHeaderActions.appearance'; +import { acpPermissionActionsAppearanceDescriptor } from '@/flow_chat/tool-cards/AcpPermissionActions.appearance'; +import { contextCompressionDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/ContextCompressionDisplay.appearance'; +import { getToolSpecCardAppearanceDescriptor } from '@/flow_chat/tool-cards/GetToolSpecCard.appearance'; +import { viewImageToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/ViewImageToolCard.appearance'; +import { acpPlanPanelAppearanceDescriptor } from '@/flow_chat/components/AcpPlanPanel.appearance'; +import { backgroundCommandInputDialogAppearanceDescriptor } from '@/flow_chat/components/background-command/BackgroundCommandInputDialog.appearance'; +import { chatEmptyStateAppearanceDescriptor } from '@/flow_chat/components/ChatEmptyState.appearance'; +import { copyOutputButtonAppearanceDescriptor } from '@/flow_chat/components/CopyOutputButton.appearance'; +import { copyableTextPreviewAppearanceDescriptor } from '@/flow_chat/components/CopyableTextPreview.appearance'; +import { currentSessionTitleAppearanceDescriptor } from '@/flow_chat/components/CurrentSessionTitle.appearance'; +import { coworkExampleCardsAppearanceDescriptor } from '@/flow_chat/components/CoworkExampleCards.appearance'; +import { imageAnalysisCardAppearanceDescriptor } from '@/flow_chat/components/ImageAnalysisCard.appearance'; +import { scrollToBottomButtonAppearanceDescriptor } from '@/flow_chat/components/ScrollToBottomButton.appearance'; +import { scrollToLatestBarAppearanceDescriptor } from '@/flow_chat/components/ScrollToLatestBar.appearance'; +import { scrollToTurnHeaderButtonAppearanceDescriptor } from '@/flow_chat/components/ScrollToTurnHeaderButton.appearance'; +import { tokenUsageIndicatorAppearanceDescriptor } from '@/flow_chat/components/TokenUsageIndicator.appearance'; +import { toolApprovalBarAppearanceDescriptor } from '@/flow_chat/components/ToolApprovalBar.appearance'; +import { snapshotRollbackButtonAppearanceDescriptor } from '@/flow_chat/components/SnapshotRollbackButton.appearance'; +import { smartRecommendationsAppearanceDescriptor } from '@/flow_chat/components/smart-recommendations/SmartRecommendations.appearance'; +import { stickyTaskIndicatorAppearanceDescriptor } from '@/flow_chat/components/StickyTaskIndicator.appearance'; +import { turnHistoryPanelAppearanceDescriptor } from '@/flow_chat/components/TurnHistoryPanel.appearance'; +import { turnRollbackButtonAppearanceDescriptor } from '@/flow_chat/components/TurnRollbackButton.appearance'; +import { sessionRuntimeStatusEntryAppearanceDescriptor } from '@/flow_chat/components/usage/SessionRuntimeStatusEntry.appearance'; +import { runtimeStatusSlotAppearanceDescriptor } from '@/flow_chat/components/modern/RuntimeStatusSlot.appearance'; +import { sessionMenuAppearanceDescriptor } from '@/flow_chat/components/session-menu/SessionMenu.appearance'; +import { + dispatchInstallDialogAppearanceDescriptor, + dispatchResultDialogAppearanceDescriptor, + dispatchTargetPickerAppearanceDescriptor, +} from '@/features/dispatch/appearance'; +import { voiceInputConfigAppearanceDescriptor } from '@/infrastructure/config/components/VoiceInputConfig.appearance'; +import { worktreesConfigAppearanceDescriptor } from '@/infrastructure/config/components/WorktreesConfig.appearance'; +import { turnCompletionNoticeAppearanceDescriptor } from '@/flow_chat/components/modern/TurnCompletionNoticeItem.appearance'; +import { turnFailureNoticeAppearanceDescriptor } from '@/flow_chat/components/modern/TurnFailureNoticeItem.appearance'; +import { virtualItemAppearanceDescriptor } from '@/flow_chat/components/modern/VirtualItemRenderer.appearance'; +import { AppearanceRegistry } from './AppearanceRegistry'; + +export function createDefaultAppearanceRegistry(): AppearanceRegistry { + return new AppearanceRegistry() + .registerComponent(buttonAppearanceDescriptor) + .registerComponent(cardAppearanceDescriptor) + .registerComponent(inputAppearanceDescriptor) + .registerComponent(modalAppearanceDescriptor) + .registerComponent(tooltipAppearanceDescriptor) + .registerComponent(iconButtonAppearanceDescriptor) + .registerComponent(checkboxAppearanceDescriptor) + .registerComponent(switchAppearanceDescriptor) + .registerComponent(textareaAppearanceDescriptor) + .registerComponent(numberInputAppearanceDescriptor) + .registerComponent(searchAppearanceDescriptor) + .registerComponent(tabsAppearanceDescriptor) + .registerComponent(selectAppearanceDescriptor) + .registerComponent(alertAppearanceDescriptor) + .registerComponent(badgeAppearanceDescriptor) + .registerComponent(tagAppearanceDescriptor) + .registerComponent(avatarAppearanceDescriptor) + .registerComponent(codeEditorAppearanceDescriptor) + .registerComponent(configPageAppearanceDescriptor) + .registerComponent(confirmDialogAppearanceDescriptor) + .registerComponent(cubeLoadingAppearanceDescriptor) + .registerComponent(cubeLogoAppearanceDescriptor) + .registerComponent(dotMatrixLoaderAppearanceDescriptor) + .registerComponent(emptyAppearanceDescriptor) + .registerComponent(filterPillAppearanceDescriptor) + .registerComponent(inputDialogAppearanceDescriptor) + .registerComponent(streamTextAppearanceDescriptor) + .registerComponent(taskRunningIndicatorAppearanceDescriptor) + .registerComponent(textStrokeEffectAppearanceDescriptor) + .registerComponent(windowControlsAppearanceDescriptor) + .registerComponent(markdownAppearanceDescriptor) + .registerComponent(flowChatCardAppearanceDescriptor) + .registerComponent(chatInputAppearanceDescriptor) + .registerComponent(composerVoiceInputAppearanceDescriptor) + .registerComponent(sessionUsagePanelAppearanceDescriptor) + .registerComponent(fileOperationToolCardAppearanceDescriptor) + .registerComponent(richTextInputAppearanceDescriptor) + .registerComponent(modelRoundItemAppearanceDescriptor) + .registerComponent(deepReviewActionBarAppearanceDescriptor) + .registerComponent(modelSelectorAppearanceDescriptor) + .registerComponent(flowChatHeaderAppearanceDescriptor) + .registerComponent(flowChatTurnRailAppearanceDescriptor) + .registerComponent(sessionFilesBadgeAppearanceDescriptor) + .registerComponent(codeReviewToolCardAppearanceDescriptor) + .registerComponent(createAgentPageAppearanceDescriptor) + .registerComponent(keyboardShortcutsAppearanceDescriptor) + .registerComponent(taskToolDisplayAppearanceDescriptor) + .registerComponent(basicsConfigAppearanceDescriptor) + .registerComponent(markdownEditorAppearanceDescriptor) + .registerComponent(planViewerAppearanceDescriptor) + .registerComponent(appLayoutAppearanceDescriptor) + .registerComponent(skillGroupPickerAppearanceDescriptor) + .registerComponent(workingCopyViewAppearanceDescriptor) + .registerComponent(assistantConfigPageAppearanceDescriptor) + .registerComponent(assistantDefaultsPageAppearanceDescriptor) + .registerComponent(taskDetailPanelAppearanceDescriptor) + .registerComponent(toolbarModeAppearanceDescriptor) + .registerComponent(mcpToolDisplayAppearanceDescriptor) + .registerComponent(terminalToolCardAppearanceDescriptor) + .registerComponent(skillsConfigAppearanceDescriptor) + .registerComponent(diffEditorAppearanceDescriptor) + .registerComponent(agentCompanionDesktopPetAppearanceDescriptor) + .registerComponent(toolGroupPickerAppearanceDescriptor) + .registerComponent(inlineDiffPreviewAppearanceDescriptor) + .registerComponent(exportImageAppearanceDescriptor) + .registerComponent(userMessageItemAppearanceDescriptor) + .registerComponent(sessionUsageReportCardAppearanceDescriptor) + .registerComponent(askUserQuestionCardAppearanceDescriptor) + .registerComponent(createPlanDisplayAppearanceDescriptor) + .registerComponent(execProcessToolCardAppearanceDescriptor) + .registerComponent(editorConfigAppearanceDescriptor) + .registerComponent(appearanceConfigAppearanceDescriptor) + .registerComponent(lspConfigAppearanceDescriptor) + .registerComponent(configAppearanceDescriptor) + .registerComponent(fontPreferenceAppearanceDescriptor) + .registerComponent(languageSelectorAppearanceDescriptor) + .registerComponent(peerDeviceAppearanceDescriptor) + .registerComponent(updateAppearanceDescriptor) + .registerComponent(componentPreviewAppearanceDescriptor) + .registerComponent(announcementAppearanceDescriptor) + .registerComponent(contextMenuAppearanceDescriptor) + .registerComponent(contextListAppearanceDescriptor) + .registerComponent(notificationAppearanceDescriptor) + .registerComponent(canvasToolAppearanceDescriptor) + .registerComponent(generativeWidgetAppearanceDescriptor) + .registerComponent(editorToolAppearanceDescriptor) + .registerComponent(fileSystemAppearanceDescriptor) + .registerComponent(gitToolAppearanceDescriptor) + .registerComponent(lspAppearanceDescriptor) + .registerComponent(terminalToolAppearanceDescriptor) + .registerComponent(workspaceToolAppearanceDescriptor) + .registerComponent(gitGraphViewAppearanceDescriptor) + .registerComponent(navBarAppearanceDescriptor) + .registerComponent(splashScreenAppearanceDescriptor) + .registerComponent(chatPaneAppearanceDescriptor) + .registerComponent(auxPaneAppearanceDescriptor) + .registerComponent(bottomTerminalPaneAppearanceDescriptor) + .registerComponent(scheduledJobsModalAppearanceDescriptor) + .registerComponent(sceneBarAppearanceDescriptor) + .registerComponent(panelHeaderAppearanceDescriptor) + .registerComponent(terminalEditModalAppearanceDescriptor) + .registerComponent(galleryLayoutAppearanceDescriptor) + .registerComponent(skillCardAppearanceDescriptor) + .registerComponent(miniAppCardAppearanceDescriptor) + .registerComponent(agentCardAppearanceDescriptor) + .registerComponent(coreAgentCardAppearanceDescriptor) + .registerComponent(agentCapabilityTooltipAppearanceDescriptor) + .registerComponent(gitNavAppearanceDescriptor) + .registerComponent(fileViewerNavAppearanceDescriptor) + .registerComponent(assistantQuickInputAppearanceDescriptor) + .registerComponent(galleryDetailModalAppearanceDescriptor) + .registerComponent(mcpInteractionDialogAppearanceDescriptor) + .registerComponent(remoteConnectDisclaimerAppearanceDescriptor) + .registerComponent(diffFullscreenViewerAppearanceDescriptor) + .registerComponent(notificationButtonAppearanceDescriptor) + .registerComponent(contextCompressionCardAppearanceDescriptor) + .registerComponent(reviewSessionSummaryCardAppearanceDescriptor) + .registerComponent(relayDeployAppearanceDescriptor) + .registerComponent(sshRemoteAppearanceDescriptor) + .registerComponent(aboutDialogAppearanceDescriptor) + .registerComponent(navPanelAppearanceDescriptor) + .registerComponent(sessionsSectionAppearanceDescriptor) + .registerComponent(contentCanvasAppearanceDescriptor) + .registerComponent(filesPanelAppearanceDescriptor) + .registerComponent(reviewPlatformAppearanceDescriptor) + .registerComponent(remoteConnectDialogAppearanceDescriptor) + .registerComponent(remoteAccountPanelAppearanceDescriptor) + .registerComponent(scheduledJobsViewAppearanceDescriptor) + .registerComponent(flexiblePanelAppearanceDescriptor) + .registerComponent(btwSessionPanelAppearanceDescriptor) + .registerComponent(modernFlowChatAppearanceDescriptor) + .registerComponent(virtualMessageListAppearanceDescriptor) + .registerComponent(aiModelConfigAppearanceDescriptor) + .registerComponent(externalSourcesConfigAppearanceDescriptor) + .registerComponent(acpAgentsConfigAppearanceDescriptor) + .registerComponent(sessionConfigAppearanceDescriptor) + .registerComponent(sessionTitleConfigAppearanceDescriptor) + .registerComponent(mcpToolsConfigAppearanceDescriptor) + .registerComponent(externalMcpOverviewAppearanceDescriptor) + .registerComponent(voiceInputDiagnosticsAppearanceDescriptor) + .registerComponent(assistantCardAppearanceDescriptor) + .registerComponent(miniAppCustomizePanelAppearanceDescriptor) + .registerComponent(userMessageEditComposerAppearanceDescriptor) + .registerComponent(tiptapEditorAppearanceDescriptor) + .registerComponent(workspaceProjectPermissionsDialogAppearanceDescriptor) + .registerComponent(workspaceSessionBatchModalAppearanceDescriptor) + .registerComponent(archivedSessionsConfigAppearanceDescriptor) + .registerComponent(settingsNavAppearanceDescriptor) + .registerComponent(backgroundCommandOutputPanelAppearanceDescriptor) + .registerComponent(chatInputPixelPetAppearanceDescriptor) + .registerComponent(fileMentionPickerAppearanceDescriptor) + .registerComponent(sessionFileModificationsBarAppearanceDescriptor) + .registerComponent(editorBreadcrumbAppearanceDescriptor) + .registerComponent(gitBranchHistoryAppearanceDescriptor) + .registerComponent(gitDiffViewAppearanceDescriptor) + .registerComponent(gitSettingsViewAppearanceDescriptor) + .registerComponent(quickActionsConfigAppearanceDescriptor) + .registerComponent(statusBarPopoversAppearanceDescriptor) + .registerComponent(mEditorAppearanceDescriptor) + .registerComponent(referencesPanelAppearanceDescriptor) + .registerComponent(navSearchDialogAppearanceDescriptor) + .registerComponent(workspaceRelatedPathsDialogAppearanceDescriptor) + .registerComponent(branchSelectModalAppearanceDescriptor) + .registerComponent(floatingMiniChatAppearanceDescriptor) + .registerComponent(miniAppBubbleWelcomeAppearanceDescriptor) + .registerComponent(branchesViewAppearanceDescriptor) + .registerComponent(miniAppGalleryViewAppearanceDescriptor) + .registerComponent(miniAppMarketViewAppearanceDescriptor) + .registerComponent(miniAppSubmissionsViewAppearanceDescriptor) + .registerComponent(shellNavAppearanceDescriptor) + .registerComponent(missionControlAppearanceDescriptor) + .registerComponent(canvasTabAppearanceDescriptor) + .registerComponent(canvasTabBarAppearanceDescriptor) + .registerComponent(chatInputWorkspaceStripAppearanceDescriptor) + .registerComponent(codePreviewAppearanceDescriptor) + .registerComponent(exploreGroupAppearanceDescriptor) + .registerComponent(pendingQueuePanelAppearanceDescriptor) + .registerComponent(subagentProjectionAppearanceDescriptor) + .registerComponent(threadGoalDialogsAppearanceDescriptor) + .registerComponent(welcomePanelAppearanceDescriptor) + .registerComponent(generativeWidgetToolCardAppearanceDescriptor) + .registerComponent(gitToolDisplayAppearanceDescriptor) + .registerComponent(snapshotFullscreenDiffViewerAppearanceDescriptor) + .registerComponent(mermaidBlockAppearanceDescriptor) + .registerComponent(defaultModelConfigAppearanceDescriptor) + .registerComponent(globalPermissionRulesDialogAppearanceDescriptor) + .registerComponent(mcpResourceBrowserAppearanceDescriptor) + .registerComponent(editorStatusBarAppearanceDescriptor) + .registerComponent(imageViewerAppearanceDescriptor) + .registerComponent(branchQuickSwitchAppearanceDescriptor) + .registerComponent(workspaceListSectionAppearanceDescriptor) + .registerComponent(workspaceItemAppearanceDescriptor) + .registerComponent(newProjectDialogAppearanceDescriptor) + .registerComponent(canvasEditorAreaAppearanceDescriptor) + .registerComponent(canvasEditorGroupAppearanceDescriptor) + .registerComponent(canvasThumbnailAppearanceDescriptor) + .registerComponent(canvasTabOverflowAppearanceDescriptor) + .registerComponent(browserPanelAppearanceDescriptor) + .registerComponent(nurseryGalleryAppearanceDescriptor) + .registerComponent(deepReviewConsentDialogAppearanceDescriptor) + .registerComponent(flowTextBlockAppearanceDescriptor) + .registerComponent(permissionRequestPanelAppearanceDescriptor) + .registerComponent(userMessageAppearanceDescriptor) + .registerComponent(toolCardAppearanceDescriptor) + .registerComponent(canvasToolCardAppearanceDescriptor) + .registerComponent(computerUseToolCardAppearanceDescriptor) + .registerComponent(defaultToolCardAppearanceDescriptor) + .registerComponent(getFileDiffDisplayAppearanceDescriptor) + .registerComponent(miniAppToolDisplayAppearanceDescriptor) + .registerComponent(modelThinkingDisplayAppearanceDescriptor) + .registerComponent(todoWriteDisplayAppearanceDescriptor) + .registerComponent(toolTimeoutIndicatorAppearanceDescriptor) + .registerComponent(webFetchCardAppearanceDescriptor) + .registerComponent(compactToolCardAppearanceDescriptor) + .registerComponent(toolCardStatusSlotAppearanceDescriptor) + .registerComponent(toolCardHeaderActionsAppearanceDescriptor) + .registerComponent(acpPermissionActionsAppearanceDescriptor) + .registerComponent(contextCompressionDisplayAppearanceDescriptor) + .registerComponent(getToolSpecCardAppearanceDescriptor) + .registerComponent(viewImageToolCardAppearanceDescriptor) + .registerComponent(acpPlanPanelAppearanceDescriptor) + .registerComponent(backgroundCommandInputDialogAppearanceDescriptor) + .registerComponent(chatEmptyStateAppearanceDescriptor) + .registerComponent(copyOutputButtonAppearanceDescriptor) + .registerComponent(copyableTextPreviewAppearanceDescriptor) + .registerComponent(currentSessionTitleAppearanceDescriptor) + .registerComponent(coworkExampleCardsAppearanceDescriptor) + .registerComponent(imageAnalysisCardAppearanceDescriptor) + .registerComponent(scrollToBottomButtonAppearanceDescriptor) + .registerComponent(scrollToLatestBarAppearanceDescriptor) + .registerComponent(scrollToTurnHeaderButtonAppearanceDescriptor) + .registerComponent(tokenUsageIndicatorAppearanceDescriptor) + .registerComponent(toolApprovalBarAppearanceDescriptor) + .registerComponent(snapshotRollbackButtonAppearanceDescriptor) + .registerComponent(smartRecommendationsAppearanceDescriptor) + .registerComponent(stickyTaskIndicatorAppearanceDescriptor) + .registerComponent(turnHistoryPanelAppearanceDescriptor) + .registerComponent(turnRollbackButtonAppearanceDescriptor) + .registerComponent(sessionRuntimeStatusEntryAppearanceDescriptor) + .registerComponent(runtimeStatusSlotAppearanceDescriptor) + .registerComponent(sessionMenuAppearanceDescriptor) + .registerComponent(dispatchInstallDialogAppearanceDescriptor) + .registerComponent(dispatchResultDialogAppearanceDescriptor) + .registerComponent(dispatchTargetPickerAppearanceDescriptor) + .registerComponent(voiceInputConfigAppearanceDescriptor) + .registerComponent(worktreesConfigAppearanceDescriptor) + .registerComponent(turnCompletionNoticeAppearanceDescriptor) + .registerComponent(turnFailureNoticeAppearanceDescriptor) + .registerComponent(virtualItemAppearanceDescriptor) + .registerScene(workbenchAppearanceDescriptor) + .registerScene(welcomeAppearanceDescriptor) + .registerScene(shellAppearanceDescriptor) + .registerScene(agentsAppearanceDescriptor) + .registerScene(assistantAppearanceDescriptor) + .registerScene(browserAppearanceDescriptor) + .registerScene(fileViewerAppearanceDescriptor) + .registerScene(gitAppearanceDescriptor) + .registerScene(miniAppGalleryAppearanceDescriptor) + .registerScene(miniAppAppearanceDescriptor) + .registerScene(insightsAppearanceDescriptor) + .registerScene(pagesAppearanceDescriptor) + .registerScene(panelViewAppearanceDescriptor) + .registerScene(profileAppearanceDescriptor) + .registerScene(sessionAppearanceDescriptor) + .registerScene(settingsAppearanceDescriptor) + .registerScene(skillsAppearanceDescriptor) + .registerScene(terminalAppearanceDescriptor) + .registerRenderer(cssTokenAppearanceAdapter) + .registerRenderer(monacoAppearanceAdapter) + .registerRenderer(xtermAppearanceAdapter) + .registerRenderer(mermaidAppearanceAdapter) + .registerRenderer(widgetAppearanceAdapter) + .registerRenderer(canvasAppearanceAdapter) + .freeze(); +} diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceBackgroundMedia.test.tsx b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceBackgroundMedia.test.tsx new file mode 100644 index 0000000000..1604150c14 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceBackgroundMedia.test.tsx @@ -0,0 +1,24 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { AppearanceBackgroundMediaLayer } from './AppearanceBackgroundMedia'; + +describe('AppearanceBackgroundMedia', () => { + it('renders a muted looping video with a host-owned poster fallback', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('data-bf-background-media="video"'); + expect(html).toContain('src="blob:test-motion"'); + expect(html).toContain('poster="blob:test-poster"'); + expect(html).toContain('autoplay=""'); + expect(html).toContain('loop=""'); + expect(html).toContain('muted=""'); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceBackgroundMedia.tsx b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceBackgroundMedia.tsx new file mode 100644 index 0000000000..d7e2ab6866 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceBackgroundMedia.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react'; +import type { ResolvedAppearanceBackgroundMedia } from '../types'; + +export function AppearanceBackgroundMediaLayer({ + media, +}: { + media: Readonly | undefined; +}) { + const videoRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video || !media?.url) return undefined; + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null; + const synchronizePlayback = (): void => { + if (document.hidden || reducedMotion?.matches) { + video.pause(); + return; + } + void video.play().catch(() => undefined); + }; + document.addEventListener('visibilitychange', synchronizePlayback); + reducedMotion?.addEventListener?.('change', synchronizePlayback); + synchronizePlayback(); + return () => { + document.removeEventListener('visibilitychange', synchronizePlayback); + reducedMotion?.removeEventListener?.('change', synchronizePlayback); + video.pause(); + }; + }, [media?.url]); + + if (!media?.url || !media.posterUrl) return null; + return ( + + ); +} diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.ts new file mode 100644 index 0000000000..faff488f59 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.ts @@ -0,0 +1,12 @@ +const OVERLAY_HOST_ID = 'bitfun-appearance-overlay-host'; + +export function getAppearanceOverlayHost(): HTMLDivElement { + const existing = document.getElementById(OVERLAY_HOST_ID); + if (existing instanceof HTMLDivElement) return existing; + + const host = document.createElement('div'); + host.id = OVERLAY_HOST_ID; + host.setAttribute('data-bf-overlay-host', 'true'); + document.body.appendChild(host); + return host; +} diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceRuntime.test.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceRuntime.test.ts new file mode 100644 index 0000000000..ec6e3e56c1 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceRuntime.test.ts @@ -0,0 +1,223 @@ +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AppearanceRegistry } from '../registry/AppearanceRegistry'; +import type { + AppearancePackage, + AppearanceRendererAdapter, + CssTokenAppearanceSettings, +} from '../types'; +import { AppearanceRuntime } from './AppearanceRuntime'; + +function pkg(id: string, background: string): AppearancePackage { + return { + schema: 'bitfun.appearance', + schemaVersion: 1, + id, + name: id, + version: '1.0.0', + mode: 'dark', + renderers: { + 'css-tokens': { + version: 1, + settings: { tokens: {}, background }, + }, + }, + }; +} + +describe('AppearanceRuntime', () => { + let dom: JSDOM; + + beforeEach(() => { + dom = new JSDOM(''); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + dom.window.close(); + }); + + it('commits revisions atomically and rolls renderer adapters back on failure', async () => { + const calls: Array | undefined> = []; + const adapter: AppearanceRendererAdapter<'css-tokens'> = { + id: 'css-tokens', + validate: () => [], + apply: async next => { + calls.push(next ? { ...next } : undefined); + if (next?.background === 'fail') throw new Error('renderer failed'); + }, + }; + const registry = new AppearanceRegistry().registerRenderer(adapter).freeze(); + const runtime = new AppearanceRuntime(registry); + const listener = vi.fn(); + runtime.subscribe(listener); + + await runtime.initialize(pkg('test.first', 'first')); + expect(document.documentElement.getAttribute('data-bf-appearance')).toBe('test.first'); + expect(document.querySelectorAll('style[data-bf-appearance-runtime]')).toHaveLength(1); + + await expect(runtime.applyPackage(pkg('test.second', 'fail'))).rejects.toThrow('renderer failed'); + expect(runtime.getSnapshot()?.id).toBe('test.first'); + expect(document.documentElement.getAttribute('data-bf-appearance')).toBe('test.first'); + expect(document.querySelectorAll('style[data-bf-appearance-runtime]')).toHaveLength(1); + expect(calls.at(-1)).toEqual({ tokens: {}, background: 'first' }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('creates host-owned blob URLs for package assets and revokes replaced URLs', async () => { + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValueOnce('blob:test-background'); + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + const registry = new AppearanceRegistry() + .registerComponent({ id: 'surface', parts: [{ id: 'root' }] }) + .freeze(); + const runtime = new AppearanceRuntime(registry); + const assetPackage: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.assets', + name: 'Assets', + version: '1.0.0', + mode: 'dark', + assets: { + background: { + kind: 'image', + mimeType: 'image/png', + source: { kind: 'package', path: 'assets/background.png' }, + }, + }, + components: { + surface: { + parts: { + root: { base: { backgroundImage: { kind: 'asset', assetId: 'background' } } }, + }, + }, + }, + }; + await runtime.initialize(assetPackage, { + background: { + mimeType: 'image/png', + bytes: new Uint8Array([1, 2, 3]).buffer, + width: 1, + height: 1, + }, + }); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(document.head.textContent).toContain('--bf-appearance-asset-background: url("blob:test-background")'); + + await runtime.applyPackage({ ...assetPackage, id: 'test.assets-next', assets: undefined, components: undefined }); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-background'); + }); + + it('resolves video background URLs without exposing the video as a CSS image variable', async () => { + vi.spyOn(URL, 'createObjectURL') + .mockReturnValueOnce('blob:test-motion') + .mockReturnValueOnce('blob:test-poster'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + const runtime = new AppearanceRuntime(new AppearanceRegistry().freeze()); + const assetPackage: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.video-background', + name: 'Video Background', + version: '1.0.0', + mode: 'dark', + requiredCapabilities: ['assets.v1', 'background-media.v1'], + assets: { + motion: { kind: 'video', mimeType: 'video/webm', source: { kind: 'package', path: 'assets/background.webm' } }, + poster: { kind: 'image', mimeType: 'image/png', source: { kind: 'package', path: 'assets/poster.png' } }, + }, + backgroundMedia: { kind: 'video', assetId: 'motion', posterAssetId: 'poster' }, + }; + + const snapshot = await runtime.initialize(assetPackage, { + motion: { mimeType: 'video/webm', bytes: new Uint8Array([1]).buffer, width: 1920, height: 1080, durationSeconds: 30 }, + poster: { mimeType: 'image/png', bytes: new Uint8Array([2]).buffer, width: 1920, height: 1080 }, + }); + + expect(snapshot.backgroundMedia).toMatchObject({ + url: 'blob:test-motion', + posterUrl: 'blob:test-poster', + fit: 'cover', + position: 'center', + }); + expect(snapshot.cssText).not.toContain('--bf-appearance-asset-motion'); + expect(snapshot.cssText).toContain('--bf-appearance-asset-poster'); + }); + + it('keeps a staged revision inactive until renderer transactions commit', async () => { + const observations: Array<{ rootRevision: string | null; stagedCss: string }> = []; + const adapter: AppearanceRendererAdapter<'css-tokens'> = { + id: 'css-tokens', + validate: () => [], + apply: next => { + if (next?.background !== 'second') return; + const stagedStyle = document.querySelector( + 'style[data-bf-appearance-runtime="2"]', + ); + observations.push({ + rootRevision: document.documentElement.getAttribute('data-bf-appearance-revision'), + stagedCss: stagedStyle?.textContent ?? '', + }); + }, + }; + const runtime = new AppearanceRuntime( + new AppearanceRegistry().registerRenderer(adapter).freeze(), + ); + + await runtime.initialize(pkg('test.same-id', 'first')); + await runtime.applyPackage(pkg('test.same-id', 'second')); + + expect(observations).toEqual([expect.objectContaining({ rootRevision: '1' })]); + expect(observations[0].stagedCss).toContain( + '[data-bf-appearance="test.same-id"][data-bf-appearance-revision="2"]', + ); + expect(document.documentElement.getAttribute('data-bf-appearance-revision')).toBe('2'); + expect(document.querySelectorAll('style[data-bf-appearance-runtime]')).toHaveLength(1); + }); + + it('applies normal cascade after legacy styles and reserves important for explicit overrides', async () => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + const registry = new AppearanceRegistry() + .registerComponent({ id: 'button', parts: [{ id: 'root' }] }) + .freeze(); + const runtime = new AppearanceRuntime(registry); + const basePackage: AppearancePackage = { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.computed-style', + name: 'Computed Style', + version: '1.0.0', + mode: 'dark', + components: { + button: { + parts: { + root: { base: { color: { kind: 'hex', value: '#00ff00' } } }, + }, + }, + }, + }; + + await runtime.initialize(basePackage); + const button = document.querySelector('button') as HTMLButtonElement; + expect(window.getComputedStyle(button).color).toBe('rgb(0, 255, 0)'); + + button.style.color = 'rgb(255, 0, 0)'; + await runtime.applyPackage({ + ...basePackage, + components: { + button: { + parts: { + root: { + cascade: 'override', + base: { color: { kind: 'hex', value: '#0000ff' } }, + }, + }, + }, + }, + }); + expect(window.getComputedStyle(button).color).toBe('rgb(0, 0, 255)'); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceRuntime.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceRuntime.ts new file mode 100644 index 0000000000..81c87be6d7 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceRuntime.ts @@ -0,0 +1,286 @@ +import { createLogger } from '@/shared/utils/logger'; +import { AppearanceCompiler } from '../compiler/AppearanceCompiler'; +import type { AppearanceRegistry } from '../registry/AppearanceRegistry'; +import type { + AppearancePackage, + AppearanceRendererId, + AppearanceRendererContext, + RegisteredAppearanceRendererAdapter, + ResolvedAppearance, + ResolvedAppearanceAsset, + StoredAppearanceAsset, +} from '../types'; + +const log = createLogger('AppearanceRuntime'); +const STYLE_ATTRIBUTE = 'data-bf-appearance-runtime'; + +interface PreparedRendererTransaction { + id: AppearanceRendererId; + commit(): void | Promise; + rollback(): void | Promise; +} + +export class AppearanceRuntime { + private readonly compiler: AppearanceCompiler; + private snapshot: ResolvedAppearance | null = null; + private revision = 0; + private listeners = new Set<() => void>(); + private applyQueue: Promise = Promise.resolve(null); + private activeAssetUrls = new Map(); + + constructor(private readonly registry: AppearanceRegistry) { + this.compiler = new AppearanceCompiler(registry); + } + + initialize( + pkg: AppearancePackage, + assets: Readonly> = {}, + ): Promise { + if (this.snapshot) return Promise.resolve(this.snapshot); + return this.applyPackage(pkg, assets); + } + + applyPackage( + pkg: AppearancePackage, + assets: Readonly> = {}, + ): Promise { + const run = () => this.compileAndApply(pkg, assets); + const result = this.applyQueue.then(run, run); + this.applyQueue = result; + return result; + } + + preflight( + pkg: AppearancePackage, + assets: Readonly> = {}, + ): ResolvedAppearance { + const compiled = this.compiler.compile(pkg, this.revision + 1); + Object.entries(compiled.assets).forEach(([id, resolved]) => { + const stored = assets[id]; + if (!stored) throw new Error(`Appearance asset bytes are missing: ${id}`); + if (stored.mimeType !== resolved.definition.mimeType) { + throw new Error(`Appearance asset MIME does not match the manifest: ${id}`); + } + }); + return compiled; + } + + getSnapshot = (): ResolvedAppearance | null => this.snapshot; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + async dispose(): Promise { + const previous = this.snapshot; + if (previous) { + const context: AppearanceRendererContext = { + revision: previous.revision + 1, + appearanceId: previous.id, + mode: previous.mode, + globals: previous.globals, + assets: {}, + }; + for (const adapter of this.registry.getRendererAdapters()) { + try { + await this.applyRenderer(adapter, undefined, previous.renderers, context); + } catch (error) { + log.warn('Renderer adapter cleanup failed', { rendererId: adapter.id, error }); + } + } + } + document.querySelectorAll(`style[${STYLE_ATTRIBUTE}]`).forEach(node => node.remove()); + this.revokeAssetUrls(this.activeAssetUrls); + this.activeAssetUrls = new Map(); + const root = document.documentElement; + root.removeAttribute('data-bf-appearance'); + root.removeAttribute('data-bf-appearance-mode'); + root.removeAttribute('data-bf-appearance-revision'); + root.removeAttribute('data-bf-appearance-root'); + this.snapshot = null; + this.emit(); + } + + private async compileAndApply( + pkg: AppearancePackage, + storedAssets: Readonly>, + ): Promise { + const nextRevision = this.revision + 1; + const compiled = this.preflight(pkg, storedAssets); + const preparedAssets = this.prepareAssets(compiled, storedAssets); + const next: ResolvedAppearance = Object.freeze({ + ...compiled, + assets: Object.freeze(preparedAssets.assets), + backgroundMedia: compiled.backgroundMedia ? Object.freeze({ + ...compiled.backgroundMedia, + url: preparedAssets.assets[compiled.backgroundMedia.assetId]?.url, + posterUrl: preparedAssets.assets[compiled.backgroundMedia.posterAssetId]?.url, + }) : undefined, + cssText: `${compiled.cssText}${preparedAssets.cssText ? `\n${preparedAssets.cssText}` : ''}`, + }); + const previous = this.snapshot; + const nextStyle = document.createElement('style'); + nextStyle.setAttribute(STYLE_ATTRIBUTE, String(nextRevision)); + nextStyle.textContent = next.cssText; + document.head.appendChild(nextStyle); + + const context: AppearanceRendererContext = { + revision: next.revision, + appearanceId: next.id, + mode: next.mode, + globals: next.globals, + assets: next.assets, + }; + const rendererTransactions = this.prepareRendererTransactions(next, previous, context); + const startedTransactions: PreparedRendererTransaction[] = []; + try { + for (const transaction of rendererTransactions) { + startedTransactions.push(transaction); + await transaction.commit(); + } + } catch (error) { + nextStyle.remove(); + this.revokeAssetUrls(preparedAssets.urls); + await this.rollbackRendererTransactions(startedTransactions); + log.error('Appearance transaction failed', { appearanceId: next.id, revision: next.revision, error }); + throw error; + } + + const root = document.documentElement; + root.setAttribute('data-bf-appearance-root', 'true'); + root.setAttribute('data-bf-appearance', next.id); + root.setAttribute('data-bf-appearance-mode', next.mode); + // Revision is the activation switch because every generated selector is revision-scoped. + root.setAttribute('data-bf-appearance-revision', String(next.revision)); + document.querySelectorAll(`style[${STYLE_ATTRIBUTE}]`).forEach(node => { + if (node !== nextStyle) node.remove(); + }); + this.revokeAssetUrls(this.activeAssetUrls); + this.activeAssetUrls = preparedAssets.urls; + + this.revision = nextRevision; + this.snapshot = next; + this.emit(); + if (next.diagnostics.length > 0) { + log.warn('Appearance applied with diagnostics', { + appearanceId: next.id, + revision: next.revision, + diagnosticCount: next.diagnostics.length, + }); + } else { + log.info('Appearance applied', { appearanceId: next.id, revision: next.revision }); + } + return next; + } + + private prepareRendererTransactions( + next: ResolvedAppearance, + previous: ResolvedAppearance | null, + context: AppearanceRendererContext, + ): PreparedRendererTransaction[] { + const previousContext: AppearanceRendererContext = { + revision: previous?.revision ?? 0, + appearanceId: previous?.id ?? next.id, + mode: previous?.mode ?? next.mode, + globals: previous?.globals ?? next.globals, + assets: previous?.assets ?? {}, + }; + return this.registry.getRendererAdapters().map(adapter => ({ + id: adapter.id, + commit: () => this.applyRenderer(adapter, next.renderers, previous?.renderers, context), + rollback: () => this.applyRenderer(adapter, previous?.renderers, next.renderers, previousContext), + })); + } + + private async rollbackRendererTransactions( + transactions: readonly PreparedRendererTransaction[], + ): Promise { + for (const transaction of [...transactions].reverse()) { + try { + await transaction.rollback(); + } catch (rollbackError) { + log.error('Renderer adapter rollback failed', { rendererId: transaction.id, rollbackError }); + } + } + } + + private applyRenderer( + adapter: RegisteredAppearanceRendererAdapter, + next: ResolvedAppearance['renderers'] | undefined, + previous: ResolvedAppearance['renderers'] | undefined, + context: AppearanceRendererContext, + ): void | Promise { + switch (adapter.id) { + case 'css-tokens': + return adapter.apply(next?.['css-tokens'], previous?.['css-tokens'], context); + case 'monaco': + return adapter.apply(next?.monaco, previous?.monaco, context); + case 'xterm': + return adapter.apply(next?.xterm, previous?.xterm, context); + case 'mermaid': + return adapter.apply(next?.mermaid, previous?.mermaid, context); + case 'generative-widget': + return adapter.apply(next?.['generative-widget'], previous?.['generative-widget'], context); + case 'bitfun-canvas': + return adapter.apply(next?.['bitfun-canvas'], previous?.['bitfun-canvas'], context); + } + } + + private prepareAssets( + appearance: ResolvedAppearance, + storedAssets: Readonly>, + ): { + assets: Record; + cssText: string; + urls: Map; + } { + const assets: Record = {}; + const urls = new Map(); + try { + Object.entries(appearance.assets).forEach(([id, resolved]) => { + const stored = storedAssets[id]; + if (!stored) throw new Error(`Appearance asset bytes are missing: ${id}`); + const url = URL.createObjectURL(new Blob([stored.bytes], { type: stored.mimeType })); + urls.set(id, url); + assets[id] = Object.freeze({ + ...resolved, + url, + width: stored.width, + height: stored.height, + durationSeconds: stored.durationSeconds, + }); + }); + } catch (error) { + this.revokeAssetUrls(urls); + throw error; + } + return { + assets, + cssText: this.compileAssetDeclarations(appearance.id, appearance.revision, assets), + urls, + }; + } + + private compileAssetDeclarations( + appearanceId: string, + revision: number, + assets: Readonly>, + ): string { + const declarations = document.createElement('div').style; + Object.entries(assets).forEach(([id, asset]) => { + if (asset.definition.kind !== 'image' || !asset.url) return; + declarations.setProperty(`--bf-appearance-asset-${id.replace(/\./g, '-')}`, `url("${asset.url}")`); + }); + if (!declarations.cssText) return ''; + return `:root[data-bf-appearance="${appearanceId}"][data-bf-appearance-revision="${revision}"]{${declarations.cssText}}`; + } + + private revokeAssetUrls(urls: ReadonlyMap): void { + urls.forEach(url => URL.revokeObjectURL(url)); + } + + private emit(): void { + this.listeners.forEach(listener => listener()); + } +} diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.test.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.test.ts new file mode 100644 index 0000000000..07990db4b0 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.test.ts @@ -0,0 +1,476 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AppearancePackageParser } from '../schema/AppearancePackageParser'; +import { MemoryAppearanceStorage } from '../storage/AppearanceStorage'; +import type { AppearanceCommittedEvent, AppearanceSyncPort } from '../sync/AppearanceSync'; +import type { AppearancePackage, ResolvedAppearance } from '../types'; +import type { AppearanceRuntime } from './AppearanceRuntime'; +import { AppearanceService } from './AppearanceService'; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), + setConfig: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ + configAPI: configMocks, +})); + +function resolved(pkg: AppearancePackage, revision: number): ResolvedAppearance { + return { + revision, + id: pkg.id, + name: pkg.name, + version: pkg.version, + mode: pkg.mode, + globals: {}, + materials: {}, + components: {}, + scenes: {}, + renderers: {}, + assets: {}, + diagnostics: [], + cssText: '', + }; +} + +function createService() { + let revision = 0; + let snapshot: ResolvedAppearance | null = null; + const runtime = { + preflight: vi.fn((pkg: AppearancePackage) => resolved(pkg, revision + 1)), + initialize: vi.fn(async (pkg: AppearancePackage) => { + snapshot = resolved(pkg, ++revision); + return snapshot; + }), + applyPackage: vi.fn(async (pkg: AppearancePackage) => { + snapshot = resolved(pkg, ++revision); + return snapshot; + }), + getSnapshot: vi.fn(() => snapshot), + dispose: vi.fn(async () => undefined), + } as unknown as AppearanceRuntime; + const parser = {} as AppearancePackageParser; + const storage = new MemoryAppearanceStorage(); + return { + runtime, + service: new AppearanceService(runtime, parser, storage), + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +class MemoryAppearanceSyncPort implements AppearanceSyncPort { + private readonly listeners = new Set<(event: AppearanceCommittedEvent) => void>(); + + publish(event: AppearanceCommittedEvent): void { + this.listeners.forEach(listener => listener(event)); + } + + subscribe(listener: (event: AppearanceCommittedEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +describe('AppearanceService', () => { + beforeEach(() => { + configMocks.getConfig.mockReset(); + configMocks.setConfig.mockReset(); + configMocks.setConfig.mockResolvedValue(undefined); + delete globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_ID__; + delete globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__; + vi.stubGlobal('window', { + matchMedia: vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_ID__; + delete globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__; + }); + + it('uses and consumes the desktop bootstrap selection before reading config', async () => { + globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_ID__ = 'bitfun-dark'; + globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__ = 'bitfun-dark'; + const { runtime, service } = createService(); + + await service.initialize(); + + expect(configMocks.getConfig).not.toHaveBeenCalled(); + expect(runtime.initialize).toHaveBeenCalledWith( + expect.objectContaining({ id: 'bitfun-dark' }), + {}, + ); + expect(service.getSnapshot()).toMatchObject({ + initialized: true, + selectedAppearanceId: 'bitfun-dark', + resolvedAppearanceId: 'bitfun-dark', + }); + expect(Object.prototype.hasOwnProperty.call(globalThis, '__BITFUN_BOOTSTRAP_APPEARANCE_ID__')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(globalThis, '__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__')).toBe(false); + }); + + it('persists explicit selections through appearance.selection', async () => { + configMocks.getConfig.mockResolvedValue('system'); + const { service } = createService(); + await service.initialize(); + + await service.select('bitfun-dark'); + + expect(configMocks.setConfig).toHaveBeenCalledWith('appearance.selection', 'bitfun-dark'); + expect(service.getSnapshot()).toMatchObject({ + selectedAppearanceId: 'bitfun-dark', + resolvedAppearanceId: 'bitfun-dark', + }); + }); + + it('falls back locally without overwriting an unavailable configured package', async () => { + configMocks.getConfig.mockResolvedValue('missing-appearance'); + const { runtime, service } = createService(); + + await service.initialize(); + + expect(configMocks.setConfig).not.toHaveBeenCalled(); + expect(runtime.initialize).toHaveBeenCalledWith( + expect.objectContaining({ id: 'bitfun-light' }), + {}, + ); + expect(service.getSnapshot()).toMatchObject({ + selectedAppearanceId: 'system', + resolvedAppearanceId: 'bitfun-light', + status: 'degraded', + }); + }); + + it('rolls the runtime back when selection persistence fails', async () => { + configMocks.getConfig.mockResolvedValue('system'); + const { runtime, service } = createService(); + await service.initialize(); + configMocks.setConfig.mockRejectedValueOnce(new Error('config unavailable')); + + await expect(service.select('bitfun-dark')).rejects.toThrow('config unavailable'); + + expect(runtime.applyPackage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: 'bitfun-dark' }), + {}, + ); + expect(runtime.applyPackage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: 'bitfun-light' }), + {}, + ); + expect(service.getSnapshot()).toMatchObject({ + status: 'ready', + selectedAppearanceId: 'system', + resolvedAppearanceId: 'bitfun-light', + }); + }); + + it('serializes rapid selections and commits the last intent', async () => { + configMocks.getConfig.mockResolvedValue('system'); + const firstWrite = deferred(); + configMocks.setConfig + .mockImplementationOnce(() => firstWrite.promise) + .mockResolvedValueOnce(undefined); + const { runtime, service } = createService(); + await service.initialize(); + + const first = service.select('bitfun-dark'); + const second = service.select('bitfun-light'); + await vi.waitFor(() => expect(configMocks.setConfig).toHaveBeenCalledTimes(1)); + expect(runtime.applyPackage).toHaveBeenCalledTimes(1); + + firstWrite.resolve(undefined); + await Promise.all([first, second]); + + expect(configMocks.setConfig.mock.calls).toEqual([ + ['appearance.selection', 'bitfun-dark'], + ['appearance.selection', 'bitfun-light'], + ]); + expect(service.getSnapshot()).toMatchObject({ + status: 'ready', + selectedAppearanceId: 'bitfun-light', + resolvedAppearanceId: 'bitfun-light', + }); + }); + + it('loads explicit previews and falls back to the package background asset', async () => { + const { service } = createService(); + const storage = new MemoryAppearanceStorage(); + const previewBytes = new Uint8Array([1, 2, 3]).buffer; + const backgroundBytes = new Uint8Array([4, 5, 6]).buffer; + const packageBase = { + archive: new ArrayBuffer(1), + importedAt: '2026-07-29T00:00:00.000Z', + }; + await storage.put({ + ...packageBase, + manifest: { + schema: 'bitfun.appearance', schemaVersion: 1, id: 'explicit.preview', name: 'Explicit', + version: '1.0.0', mode: 'light', preview: { kind: 'asset', assetId: 'hero' }, + }, + assets: { hero: { mimeType: 'image/webp', bytes: previewBytes, width: 16, height: 9 } }, + }); + await storage.put({ + ...packageBase, + manifest: { + schema: 'bitfun.appearance', schemaVersion: 1, id: 'fallback.preview', name: 'Fallback', + version: '1.0.0', mode: 'light', + }, + assets: { background: { mimeType: 'image/png', bytes: backgroundBytes, width: 16, height: 9 } }, + }); + const previewService = new AppearanceService({} as AppearanceRuntime, {} as AppearancePackageParser, storage); + + expect((await previewService.getPreviewAsset('explicit.preview'))?.bytes).toBe(previewBytes); + expect((await previewService.getPreviewAsset('fallback.preview'))?.bytes).toBe(backgroundBytes); + expect(await service.getPreviewAsset('missing')).toBeNull(); + }); + + it('skips video assets when selecting an implicit preview', async () => { + const storage = new MemoryAppearanceStorage(); + const videoBytes = new Uint8Array([1, 2, 3]).buffer; + const posterBytes = new Uint8Array([4, 5, 6]).buffer; + await storage.put({ + archive: new ArrayBuffer(1), + importedAt: '2026-07-29T00:00:00.000Z', + manifest: { + schema: 'bitfun.appearance', schemaVersion: 1, id: 'video.preview', name: 'Video', + version: '1.0.0', mode: 'dark', + }, + assets: { + motion: { mimeType: 'video/webm', bytes: videoBytes, width: 1920, height: 1080, durationSeconds: 10 }, + poster: { mimeType: 'image/webp', bytes: posterBytes, width: 1920, height: 1080 }, + }, + }); + const previewService = new AppearanceService({} as AppearanceRuntime, {} as AppearancePackageParser, storage); + + expect((await previewService.getPreviewAsset('video.preview'))?.bytes).toBe(posterBytes); + }); + + it('synchronizes committed selections without persisting them twice', async () => { + configMocks.getConfig.mockResolvedValue('system'); + const syncPort = new MemoryAppearanceSyncPort(); + const storage = new MemoryAppearanceStorage(); + const createSyncedService = () => { + let revision = 0; + const runtime = { + preflight: vi.fn((pkg: AppearancePackage) => resolved(pkg, revision + 1)), + initialize: vi.fn(async (pkg: AppearancePackage) => resolved(pkg, ++revision)), + applyPackage: vi.fn(async (pkg: AppearancePackage) => resolved(pkg, ++revision)), + getSnapshot: vi.fn(() => null), + dispose: vi.fn(async () => undefined), + } as unknown as AppearanceRuntime; + return new AppearanceService(runtime, {} as AppearancePackageParser, storage, syncPort); + }; + const first = createSyncedService(); + const second = createSyncedService(); + await Promise.all([first.initialize(), second.initialize()]); + + await first.select('bitfun-dark'); + await vi.waitFor(() => { + expect(second.getSnapshot().selectedAppearanceId).toBe('bitfun-dark'); + }); + + expect(configMocks.setConfig).toHaveBeenCalledTimes(1); + expect(second.getSnapshot()).toMatchObject({ + status: 'ready', + resolvedAppearanceId: 'bitfun-dark', + }); + }); + + it('keeps the previous active package when replacing storage fails', async () => { + const storage = new MemoryAppearanceStorage(); + const oldPackage = { + manifest: { + schema: 'bitfun.appearance', schemaVersion: 1, id: 'sample.active', name: 'Old', + version: '1.0.0', mode: 'dark', + } as AppearancePackage, + archive: new ArrayBuffer(1), + assets: {}, + importedAt: '2026-07-29T00:00:00.000Z', + }; + const newPackage = { + ...oldPackage, + manifest: { ...oldPackage.manifest, name: 'New', version: '2.0.0' }, + archive: new ArrayBuffer(2), + importedAt: '2026-07-30T00:00:00.000Z', + }; + await storage.put(oldPackage); + configMocks.getConfig.mockResolvedValue('sample.active'); + const runtime = { + preflight: vi.fn((pkg: AppearancePackage) => resolved(pkg, 1)), + initialize: vi.fn(async (pkg: AppearancePackage) => resolved(pkg, 1)), + applyPackage: vi.fn(async (pkg: AppearancePackage) => resolved(pkg, 2)), + getSnapshot: vi.fn(() => null), + dispose: vi.fn(async () => undefined), + } as unknown as AppearanceRuntime; + const parser = { parse: vi.fn(async () => newPackage) } as unknown as AppearancePackageParser; + const service = new AppearanceService(runtime, parser, storage); + await service.initialize(); + vi.spyOn(storage, 'put').mockRejectedValueOnce(new Error('storage unavailable')); + + await expect(service.importPackage(new ArrayBuffer(3))).rejects.toThrow('storage unavailable'); + + expect((await storage.get('sample.active'))?.manifest.version).toBe('1.0.0'); + expect(runtime.applyPackage).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'sample.active', version: '1.0.0' }), + {}, + ); + expect(service.getSnapshot()).toMatchObject({ + status: 'ready', + selectedAppearanceId: 'sample.active', + }); + }); + + it('restores a deleted package when catalog refresh fails', async () => { + const storage = new MemoryAppearanceStorage(); + const stored = { + manifest: { + schema: 'bitfun.appearance', schemaVersion: 1, id: 'sample.saved', name: 'Saved', + version: '1.0.0', mode: 'dark', + } as AppearancePackage, + archive: new ArrayBuffer(1), + assets: {}, + importedAt: '2026-07-29T00:00:00.000Z', + }; + await storage.put(stored); + configMocks.getConfig.mockResolvedValue('system'); + const { runtime } = createService(); + const service = new AppearanceService(runtime, {} as AppearancePackageParser, storage); + await service.initialize(); + vi.spyOn(storage, 'listCatalog').mockRejectedValueOnce(new Error('catalog unavailable')); + + await expect(service.deletePackage('sample.saved')).rejects.toThrow('catalog unavailable'); + + expect(await storage.get('sample.saved')).toEqual(stored); + expect(configMocks.setConfig).not.toHaveBeenCalled(); + expect(service.getSnapshot()).toMatchObject({ + status: 'ready', + selectedAppearanceId: 'system', + }); + }); + + it('restores package, selection, and runtime when active deletion cannot persist', async () => { + const storage = new MemoryAppearanceStorage(); + const stored = { + manifest: { + schema: 'bitfun.appearance', schemaVersion: 1, id: 'sample.active-delete', name: 'Active', + version: '1.0.0', mode: 'dark', + } as AppearancePackage, + archive: new ArrayBuffer(1), + assets: {}, + importedAt: '2026-07-29T00:00:00.000Z', + }; + await storage.put(stored); + configMocks.getConfig.mockResolvedValue('sample.active-delete'); + const { runtime } = createService(); + const service = new AppearanceService(runtime, {} as AppearancePackageParser, storage); + await service.initialize(); + configMocks.setConfig + .mockRejectedValueOnce(new Error('config unavailable')) + .mockResolvedValueOnce(undefined); + + await expect(service.deletePackage('sample.active-delete')).rejects.toThrow('config unavailable'); + + expect(await storage.get('sample.active-delete')).toEqual(stored); + expect(configMocks.setConfig.mock.calls).toEqual([ + ['appearance.selection', 'system'], + ['appearance.selection', 'sample.active-delete'], + ]); + expect(runtime.applyPackage).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'sample.active-delete' }), + {}, + ); + expect(service.getSnapshot()).toMatchObject({ + status: 'ready', + selectedAppearanceId: 'sample.active-delete', + resolvedAppearanceId: 'sample.active-delete', + }); + }); + + it('reports the actual runtime when active deletion rollback is incomplete', async () => { + const storage = new MemoryAppearanceStorage(); + const stored = { + manifest: { + schema: 'bitfun.appearance', schemaVersion: 1, id: 'sample.degraded-delete', name: 'Degraded', + version: '1.0.0', mode: 'dark', + } as AppearancePackage, + archive: new ArrayBuffer(1), + assets: {}, + importedAt: '2026-07-29T00:00:00.000Z', + }; + await storage.put(stored); + configMocks.getConfig.mockResolvedValue('sample.degraded-delete'); + let runtimeSnapshot = resolved(stored.manifest, 1); + const runtime = { + preflight: vi.fn((pkg: AppearancePackage) => resolved(pkg, 2)), + initialize: vi.fn(async () => runtimeSnapshot), + applyPackage: vi.fn() + .mockImplementationOnce(async (pkg: AppearancePackage) => { + runtimeSnapshot = resolved(pkg, 2); + return runtimeSnapshot; + }) + .mockRejectedValueOnce(new Error('runtime rollback unavailable')), + getSnapshot: vi.fn(() => runtimeSnapshot), + dispose: vi.fn(async () => undefined), + } as unknown as AppearanceRuntime; + const service = new AppearanceService(runtime, {} as AppearancePackageParser, storage); + await service.initialize(); + configMocks.setConfig + .mockRejectedValueOnce(new Error('config unavailable')) + .mockResolvedValueOnce(undefined); + + await expect(service.deletePackage('sample.degraded-delete')).rejects.toThrow('config unavailable'); + + expect(await storage.get('sample.degraded-delete')).toEqual(stored); + expect(service.getSnapshot()).toMatchObject({ + status: 'degraded', + selectedAppearanceId: 'system', + resolvedAppearanceId: 'bitfun-light', + current: expect.objectContaining({ id: 'bitfun-light' }), + }); + }); + + it('reconciles a missed selection event when the window regains focus', async () => { + let focusListener: (() => void) | undefined; + vi.stubGlobal('window', { + matchMedia: vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + addEventListener: vi.fn((type: string, listener: () => void) => { + if (type === 'focus') focusListener = listener; + }), + removeEventListener: vi.fn(), + }); + configMocks.getConfig.mockResolvedValueOnce('system').mockResolvedValueOnce('bitfun-dark'); + const { service } = createService(); + await service.initialize(); + + focusListener?.(); + await vi.waitFor(() => { + expect(service.getSnapshot().selectedAppearanceId).toBe('bitfun-dark'); + }); + + expect(configMocks.setConfig).not.toHaveBeenCalled(); + expect(service.getSnapshot()).toMatchObject({ + status: 'ready', + resolvedAppearanceId: 'bitfun-dark', + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts new file mode 100644 index 0000000000..411259b933 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts @@ -0,0 +1,689 @@ +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { createLogger } from '@/shared/utils/logger'; +import { + builtinAppearanceCatalog, + getBuiltinAppearance, + getSystemAppearanceId, +} from '../builtins/catalog'; +import { composeAppearancePackage } from '../builtins/composeAppearancePackage'; +import type { AppearancePackageParser } from '../schema/AppearancePackageParser'; +import { + noopAppearanceSyncPort, + type AppearanceCommittedEvent, + type AppearanceSyncPort, +} from '../sync/AppearanceSync'; +import { + SYSTEM_APPEARANCE_ID, + type AppearanceCatalogEntry, + type AppearancePackage, + type AppearanceSelectionId, + type AppearanceServiceSnapshot, + type AppearanceStorage, + type ResolvedAppearance, + type StoredAppearanceAsset, + type StoredAppearancePackage, +} from '../types'; +import type { AppearanceRuntime } from './AppearanceRuntime'; + +const log = createLogger('AppearanceService'); +const APPEARANCE_SELECTION_CONFIG_PATH = 'appearance.selection'; +const MAX_SEEN_SYNC_EVENTS = 256; + +interface AppearanceSource { + pkg: AppearancePackage; + assets: StoredAppearancePackage['assets']; +} + +interface ApplySelectionOptions { + persist: boolean; + publish: boolean; + initializeRuntime?: boolean; + force?: boolean; +} + +declare global { + // Injected by the desktop webview initialization script before the frontend + // bundle runs. Both values are single-use startup hints. + var __BITFUN_BOOTSTRAP_APPEARANCE_ID__: string | undefined; + var __BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__: string | undefined; +} + +function consumeBootstrapAppearance(): { + resolvedId?: string; + selection?: AppearanceSelectionId; +} { + const resolvedId = globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_ID__; + const selection = globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__; + delete globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_ID__; + delete globalThis.__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__; + return { + resolvedId: typeof resolvedId === 'string' && resolvedId.trim() ? resolvedId.trim() : undefined, + selection: typeof selection === 'string' && selection.trim() ? selection.trim() : undefined, + }; +} + +function importedCatalogEntry(value: { + id: string; + name: string; + author?: string; + description?: string; + version: string; + mode: 'light' | 'dark'; + importedAt: string; +}): AppearanceCatalogEntry { + return { ...value, source: 'imported' }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function createEventId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export class AppearanceService { + private snapshot: AppearanceServiceSnapshot = { + initialized: false, + status: 'initializing', + selectedAppearanceId: SYSTEM_APPEARANCE_ID, + resolvedAppearanceId: null, + appearances: builtinAppearanceCatalog, + current: null, + }; + private listeners = new Set<(snapshot: AppearanceServiceSnapshot) => void>(); + private initializePromise: Promise | null = null; + private mutationQueue: Promise = Promise.resolve(); + private systemMedia: MediaQueryList | null = null; + private syncUnsubscribe: (() => void) | null = null; + private reconciliationQueued = false; + private activeSource: AppearanceSource | null = null; + private persistedSelectionId: AppearanceSelectionId | null = null; + private readonly sourceId = createEventId(); + private readonly seenSyncEvents = new Set(); + + private readonly handleSystemAppearanceChange = () => { + void this.initialize() + .then(() => this.enqueueMutation(async () => { + if (this.snapshot.selectedAppearanceId !== SYSTEM_APPEARANCE_ID) return; + await this.applySelectionTransaction(SYSTEM_APPEARANCE_ID, { + persist: false, + publish: false, + force: true, + }); + })) + .catch(error => { + log.error('Failed to follow system appearance', { error }); + }); + }; + private readonly handleWindowFocus = () => this.scheduleReconciliation(); + private readonly handleVisibilityChange = () => { + if (typeof document === 'undefined' || document.visibilityState === 'visible') { + this.scheduleReconciliation(); + } + }; + + constructor( + private readonly runtime: AppearanceRuntime, + private readonly parser: AppearancePackageParser, + private readonly storage: AppearanceStorage, + private readonly syncPort: AppearanceSyncPort = noopAppearanceSyncPort, + ) {} + + initialize(): Promise { + if (this.initializePromise) return this.initializePromise; + this.initializePromise = this.initializeInternal(); + return this.initializePromise; + } + + getSnapshot(): AppearanceServiceSnapshot { + return this.snapshot; + } + + getPackage(id: string): Promise { + const builtin = getBuiltinAppearance(id); + if (builtin) return Promise.resolve(builtin); + return this.storage.get(id).then(stored => stored?.manifest ?? null); + } + + async getPreviewAsset(id: string): Promise { + const stored = await this.storage.get(id); + if (!stored) return null; + const explicitId = stored.manifest.preview?.assetId; + const fallbackId = explicitId + ?? (stored.assets.preview?.mimeType.startsWith('image/') ? 'preview' : undefined) + ?? (stored.assets.background?.mimeType.startsWith('image/') ? 'background' : undefined) + ?? Object.keys(stored.assets).find(assetId => stored.assets[assetId].mimeType.startsWith('image/')); + return fallbackId ? stored.assets[fallbackId] ?? null : null; + } + + subscribe(listener: (snapshot: AppearanceServiceSnapshot) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async select(id: AppearanceSelectionId): Promise { + await this.initialize(); + await this.enqueueMutation(() => this.applySelectionTransaction(id, { + persist: true, + publish: true, + })); + } + + async importPackage(source: ArrayBuffer): Promise { + await this.initialize(); + const stored = await this.parser.parse(source); + if (getBuiltinAppearance(stored.manifest.id)) { + throw new Error(`Imported appearance cannot replace a builtin appearance: ${stored.manifest.id}`); + } + const composed = composeAppearancePackage(stored.manifest); + this.runtime.preflight(composed, stored.assets); + + return this.enqueueMutation(async () => { + const id = stored.manifest.id; + const previousStored = await this.storage.get(id); + const previousSnapshot = this.snapshot; + const previousSource = this.activeSource; + const active = previousSnapshot.selectedAppearanceId === id; + let applied: ResolvedAppearance | null = null; + let storageCommitted = false; + this.setApplying(active ? id : undefined); + + try { + if (active) applied = await this.runtime.applyPackage(composed, stored.assets); + await this.storage.put(stored); + storageCommitted = true; + const appearances = await this.loadCatalog(); + if (active && applied) this.activeSource = { pkg: composed, assets: stored.assets }; + this.setSnapshot({ + ...previousSnapshot, + status: 'ready', + pendingSelectionId: undefined, + lastError: undefined, + appearances, + ...(applied ? { current: applied, resolvedAppearanceId: id } : {}), + }); + const imported = appearances.find(item => item.id === id); + if (!imported) throw new Error(`Imported appearance catalog entry is missing: ${id}`); + await this.publish({ + kind: 'package-upserted', + eventId: createEventId(), + sourceId: this.sourceId, + packageId: id, + version: stored.manifest.version, + }); + return imported; + } catch (error) { + const compensationErrors: string[] = []; + if (storageCommitted) { + try { + await this.restoreStoredPackage(id, previousStored); + } catch (restoreError) { + compensationErrors.push(`storage rollback failed: ${errorMessage(restoreError)}`); + } + } + let restoredCurrent = previousSnapshot.current; + let restoredResolvedAppearanceId = previousSnapshot.resolvedAppearanceId; + if (active) { + try { + restoredCurrent = await this.restoreRuntime(previousSource, previousSnapshot.current); + this.activeSource = previousSource; + } catch (restoreError) { + compensationErrors.push(`runtime rollback failed: ${errorMessage(restoreError)}`); + const runtimeSnapshot = this.runtime.getSnapshot(); + restoredCurrent = runtimeSnapshot; + restoredResolvedAppearanceId = runtimeSnapshot?.id ?? null; + this.activeSource = runtimeSnapshot?.id === id + ? { pkg: composed, assets: stored.assets } + : previousSource; + } + } else { + this.activeSource = previousSource; + } + this.setSnapshot({ + ...previousSnapshot, + status: compensationErrors.length > 0 ? 'degraded' : 'ready', + pendingSelectionId: undefined, + lastError: [errorMessage(error), ...compensationErrors].join('; '), + current: restoredCurrent, + resolvedAppearanceId: restoredResolvedAppearanceId, + }); + throw error; + } + }); + } + + async exportPackage(id: string): Promise { + const stored = await this.storage.get(id); + if (!stored) throw new Error(`Imported appearance package not found: ${id}`); + return stored.archive.slice(0); + } + + activate(id: string): Promise { + return this.select(id); + } + + deactivate(): Promise { + return this.select(SYSTEM_APPEARANCE_ID); + } + + async deletePackage(id: string): Promise { + await this.initialize(); + await this.enqueueMutation(async () => { + if (getBuiltinAppearance(id)) throw new Error(`Builtin appearance cannot be deleted: ${id}`); + const previousStored = await this.storage.get(id); + const previousSnapshot = this.snapshot; + const wasActive = previousSnapshot.selectedAppearanceId === id; + let storageCommitted = false; + try { + await this.storage.delete(id); + storageCommitted = true; + const appearances = await this.loadCatalog(); + if (wasActive) { + await this.applySelectionTransaction(SYSTEM_APPEARANCE_ID, { + persist: true, + publish: false, + }); + } + this.setSnapshot({ ...this.snapshot, appearances }); + } catch (error) { + const compensationErrors: string[] = []; + const selectionCompensationDegraded = this.snapshot.status === 'degraded'; + if (storageCommitted && previousStored) { + try { + await this.storage.put(previousStored); + } catch (restoreError) { + compensationErrors.push(`storage rollback failed: ${errorMessage(restoreError)}`); + } + } + if (selectionCompensationDegraded) { + compensationErrors.push('selection rollback did not fully restore persisted state'); + } + const currentSnapshot = this.runtime.getSnapshot(); + const selectedAppearanceId = currentSnapshot?.id === getSystemAppearanceId() + ? SYSTEM_APPEARANCE_ID + : previousSnapshot.selectedAppearanceId; + this.setSnapshot({ + ...previousSnapshot, + status: compensationErrors.length > 0 ? 'degraded' : 'ready', + pendingSelectionId: undefined, + lastError: [errorMessage(error), ...compensationErrors].join('; '), + selectedAppearanceId, + current: currentSnapshot ?? previousSnapshot.current, + resolvedAppearanceId: currentSnapshot?.id ?? previousSnapshot.resolvedAppearanceId, + }); + throw error; + } + if (wasActive) await this.publishSelection(SYSTEM_APPEARANCE_ID); + await this.publish({ + kind: 'package-deleted', + eventId: createEventId(), + sourceId: this.sourceId, + packageId: id, + }); + }); + } + + async dispose(): Promise { + this.detachSystemListener(); + this.detachReconciliationListeners(); + this.syncUnsubscribe?.(); + this.syncUnsubscribe = null; + this.syncPort.dispose?.(); + await this.runtime.dispose(); + this.activeSource = null; + } + + private async initializeInternal(): Promise { + this.attachSyncListener(); + this.setSnapshot({ ...this.snapshot, appearances: await this.loadCatalog() }); + const bootstrap = consumeBootstrapAppearance(); + const configuredSelection = bootstrap.selection + ?? await configAPI.getConfig(APPEARANCE_SELECTION_CONFIG_PATH, { skipRetryOnNotFound: true }); + const selected = typeof configuredSelection === 'string' && configuredSelection.trim() + ? configuredSelection.trim() + : SYSTEM_APPEARANCE_ID; + this.persistedSelectionId = selected; + try { + await this.applySelectionTransaction(selected, { + persist: false, + publish: false, + initializeRuntime: true, + force: true, + }); + if (bootstrap.resolvedId && bootstrap.resolvedId !== this.snapshot.resolvedAppearanceId) { + log.debug('Desktop bootstrap appearance differed from the initialized runtime appearance', { + bootstrapAppearanceId: bootstrap.resolvedId, + runtimeAppearanceId: this.snapshot.resolvedAppearanceId, + }); + } + } catch (error) { + log.error('Configured appearance activation failed', { appearanceId: selected, error }); + await this.applySelectionTransaction(SYSTEM_APPEARANCE_ID, { + persist: false, + publish: false, + initializeRuntime: true, + force: true, + }); + this.setSnapshot({ + ...this.snapshot, + status: 'degraded', + lastError: errorMessage(error), + }); + } + this.attachSystemListener(); + this.attachReconciliationListeners(); + } + + private async applySelectionTransaction( + selected: AppearanceSelectionId, + options: ApplySelectionOptions, + ): Promise { + const resolvedId = selected === SYSTEM_APPEARANCE_ID ? getSystemAppearanceId() : selected; + const unchanged = this.snapshot.initialized + && this.snapshot.selectedAppearanceId === selected + && this.snapshot.resolvedAppearanceId === resolvedId + && (!options.persist || this.persistedSelectionId === selected); + if (unchanged && !options.force) return; + + const resolved = await this.resolvePackage(resolvedId); + if (!resolved) throw new Error(`Appearance package not found: ${resolvedId}`); + this.runtime.preflight(resolved.pkg, resolved.assets); + + const previousSnapshot = this.snapshot; + const previousSource = this.activeSource; + const previousPersistedSelectionId = this.persistedSelectionId; + this.setApplying(selected); + let runtimeApplied = false; + let persistenceAttempted = false; + try { + const current = options.initializeRuntime + ? await this.runtime.initialize(resolved.pkg, resolved.assets) + : await this.runtime.applyPackage(resolved.pkg, resolved.assets); + runtimeApplied = true; + if (options.persist) { + persistenceAttempted = true; + await configAPI.setConfig(APPEARANCE_SELECTION_CONFIG_PATH, selected); + this.persistedSelectionId = selected; + } + this.activeSource = resolved; + this.setSnapshot({ + ...previousSnapshot, + initialized: true, + status: 'ready', + pendingSelectionId: undefined, + lastError: undefined, + selectedAppearanceId: selected, + resolvedAppearanceId: resolvedId, + current, + }); + if (options.publish) await this.publishSelection(selected); + } catch (error) { + const compensationErrors: string[] = []; + if (persistenceAttempted && previousPersistedSelectionId !== null) { + try { + await configAPI.setConfig(APPEARANCE_SELECTION_CONFIG_PATH, previousPersistedSelectionId); + this.persistedSelectionId = previousPersistedSelectionId; + } catch (rollbackError) { + compensationErrors.push(`selection rollback failed: ${errorMessage(rollbackError)}`); + } + } + let restoredCurrent = previousSnapshot.current; + if (runtimeApplied) { + try { + restoredCurrent = await this.restoreRuntime(previousSource, previousSnapshot.current); + } catch (rollbackError) { + compensationErrors.push(`runtime rollback failed: ${errorMessage(rollbackError)}`); + const runtimeSnapshot = this.runtime.getSnapshot(); + this.activeSource = runtimeSnapshot?.id === resolvedId ? resolved : previousSource; + this.setSnapshot({ + ...previousSnapshot, + initialized: true, + status: 'degraded', + pendingSelectionId: undefined, + lastError: [errorMessage(error), ...compensationErrors].join('; '), + selectedAppearanceId: runtimeSnapshot?.id === resolvedId + ? selected + : previousSnapshot.selectedAppearanceId, + resolvedAppearanceId: runtimeSnapshot?.id ?? null, + current: runtimeSnapshot, + }); + throw error; + } + } + this.activeSource = previousSource; + this.setSnapshot({ + ...previousSnapshot, + status: compensationErrors.length > 0 + ? 'degraded' + : previousSnapshot.initialized ? 'ready' : 'initializing', + pendingSelectionId: undefined, + lastError: [errorMessage(error), ...compensationErrors].join('; '), + current: restoredCurrent, + }); + throw error; + } + } + + private async restoreRuntime( + source: AppearanceSource | null, + fallback: ResolvedAppearance | null, + ): Promise { + if (!source) return fallback; + return this.runtime.applyPackage(source.pkg, source.assets); + } + + private async restoreStoredPackage( + id: string, + previous: StoredAppearancePackage | null, + ): Promise { + try { + if (previous) await this.storage.put(previous); + else await this.storage.delete(id); + } catch (error) { + log.error('Failed to restore appearance package storage', { appearanceId: id, error }); + throw error; + } + } + + private async resolvePackage(id: string): Promise { + const builtin = getBuiltinAppearance(id); + if (builtin) return { pkg: builtin, assets: {} }; + const stored = await this.storage.get(id); + return stored ? { pkg: composeAppearancePackage(stored.manifest), assets: stored.assets } : null; + } + + private async loadCatalog(): Promise { + const imported = (await this.storage.listCatalog()).map(importedCatalogEntry); + return [...builtinAppearanceCatalog, ...imported]; + } + + private attachSystemListener(): void { + if (this.systemMedia || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + this.systemMedia = window.matchMedia('(prefers-color-scheme: dark)'); + this.systemMedia.addEventListener('change', this.handleSystemAppearanceChange); + } + + private detachSystemListener(): void { + this.systemMedia?.removeEventListener('change', this.handleSystemAppearanceChange); + this.systemMedia = null; + } + + private attachReconciliationListeners(): void { + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('focus', this.handleWindowFocus); + } + if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + document.addEventListener('visibilitychange', this.handleVisibilityChange); + } + } + + private detachReconciliationListeners(): void { + if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') { + window.removeEventListener('focus', this.handleWindowFocus); + } + if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + } + } + + private scheduleReconciliation(): void { + if (this.reconciliationQueued) return; + this.reconciliationQueued = true; + void this.initialize() + .then(() => this.enqueueMutation(() => this.reconcileExternalState())) + .catch(error => { + log.error('Failed to reconcile appearance state', { error }); + this.setSnapshot({ + ...this.snapshot, + status: 'degraded', + pendingSelectionId: undefined, + lastError: errorMessage(error), + }); + }) + .finally(() => { + this.reconciliationQueued = false; + }); + } + + private async reconcileExternalState(): Promise { + const configuredSelection = await configAPI.getConfig( + APPEARANCE_SELECTION_CONFIG_PATH, + { skipRetryOnNotFound: true }, + ); + const selected = typeof configuredSelection === 'string' && configuredSelection.trim() + ? configuredSelection.trim() + : SYSTEM_APPEARANCE_ID; + const appearances = await this.loadCatalog(); + this.persistedSelectionId = selected; + this.setSnapshot({ ...this.snapshot, appearances }); + + const resolvedId = selected === SYSTEM_APPEARANCE_ID ? getSystemAppearanceId() : selected; + const importedSelection = !getBuiltinAppearance(resolvedId); + const needsApply = this.snapshot.selectedAppearanceId !== selected + || this.snapshot.resolvedAppearanceId !== resolvedId + || importedSelection; + if (!needsApply) return; + + try { + await this.applySelectionTransaction(selected, { + persist: false, + publish: false, + force: true, + }); + } catch (error) { + await this.applySelectionTransaction(SYSTEM_APPEARANCE_ID, { + persist: false, + publish: false, + force: true, + }); + this.setSnapshot({ + ...this.snapshot, + status: 'degraded', + lastError: errorMessage(error), + }); + } + } + + private attachSyncListener(): void { + if (this.syncUnsubscribe) return; + this.syncUnsubscribe = this.syncPort.subscribe(event => { + if (event.sourceId === this.sourceId || this.seenSyncEvents.has(event.eventId)) return; + this.rememberSyncEvent(event.eventId); + void this.initialize() + .then(() => this.enqueueMutation(() => this.applySyncEvent(event))) + .catch(error => { + log.error('Failed to synchronize appearance mutation', { event, error }); + this.setSnapshot({ + ...this.snapshot, + status: 'degraded', + pendingSelectionId: undefined, + lastError: errorMessage(error), + }); + }); + }); + } + + private async applySyncEvent(event: AppearanceCommittedEvent): Promise { + if (event.kind === 'selection-changed') { + this.persistedSelectionId = event.selectionId; + await this.applySelectionTransaction(event.selectionId, { + persist: false, + publish: false, + force: true, + }); + return; + } + + const appearances = await this.loadCatalog(); + this.setSnapshot({ ...this.snapshot, appearances }); + if (event.kind === 'package-upserted') { + if (this.snapshot.selectedAppearanceId === event.packageId) { + await this.applySelectionTransaction(event.packageId, { + persist: false, + publish: false, + force: true, + }); + } + return; + } + + if (this.snapshot.selectedAppearanceId === event.packageId) { + await this.applySelectionTransaction(SYSTEM_APPEARANCE_ID, { + persist: false, + publish: false, + force: true, + }); + } + } + + private async publishSelection(selectionId: AppearanceSelectionId): Promise { + await this.publish({ + kind: 'selection-changed', + eventId: createEventId(), + sourceId: this.sourceId, + selectionId, + }); + } + + private async publish(event: AppearanceCommittedEvent): Promise { + try { + await this.syncPort.publish(event); + } catch (error) { + log.warn('Failed to publish appearance mutation', { event, error }); + } + } + + private rememberSyncEvent(eventId: string): void { + this.seenSyncEvents.add(eventId); + if (this.seenSyncEvents.size <= MAX_SEEN_SYNC_EVENTS) return; + const oldest = this.seenSyncEvents.values().next().value as string | undefined; + if (oldest) this.seenSyncEvents.delete(oldest); + } + + private enqueueMutation(operation: () => Promise): Promise { + const result = this.mutationQueue.then(operation, operation); + this.mutationQueue = result.then(() => undefined, () => undefined); + return result; + } + + private setApplying(pendingSelectionId?: AppearanceSelectionId): void { + this.setSnapshot({ + ...this.snapshot, + status: 'applying', + pendingSelectionId, + lastError: undefined, + }); + } + + private setSnapshot(snapshot: AppearanceServiceSnapshot): void { + this.snapshot = Object.freeze({ + ...snapshot, + appearances: Object.freeze([...snapshot.appearances]), + }); + this.listeners.forEach(listener => listener(this.snapshot)); + } +} diff --git a/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.test.ts b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.test.ts new file mode 100644 index 0000000000..e44516d21f --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.test.ts @@ -0,0 +1,132 @@ +import JSZip from 'jszip'; +import { describe, expect, it } from 'vitest'; +import { createDefaultAppearanceRegistry } from '../registry/defaultAppearanceRegistry'; +import type { AppearancePackage } from '../types'; +import { AppearancePackageParser } from './AppearancePackageParser'; + +function pngHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + bytes.set([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52], 8); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return bytes; +} + +function webmHeader(): Uint8Array { + return new Uint8Array([ + 0x1a, 0x45, 0xdf, 0xa3, 0x42, 0x82, 0x84, + 0x77, 0x65, 0x62, 0x6d, 0x42, 0x87, 0x81, 0x04, 0x00, + ]); +} + +function manifest(): AppearancePackage { + return { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.archive', + name: 'Archive', + version: '1.0.0', + mode: 'dark', + assets: { + background: { + kind: 'image', + mimeType: 'image/png', + source: { kind: 'package', path: 'assets/background.png' }, + }, + }, + components: { + card: { + parts: { + root: { base: { backgroundImage: { kind: 'asset', assetId: 'background' } } }, + }, + }, + }, + }; +} + +async function archive(value: unknown, extra?: (zip: JSZip) => void): Promise { + const zip = new JSZip(); + zip.file('appearance.json', JSON.stringify(value)); + zip.file('assets/background.png', pngHeader(320, 180)); + extra?.(zip); + return zip.generateAsync({ type: 'arraybuffer' }); +} + +describe('AppearancePackageParser', () => { + const parser = new AppearancePackageParser(createDefaultAppearanceRegistry()); + + it('imports only declared, validated image assets', async () => { + const source = await archive(manifest()); + const stored = await parser.parse(source); + expect(stored.manifest.id).toBe('test.archive'); + expect(stored.assets.background).toMatchObject({ + mimeType: 'image/png', + width: 320, + height: 180, + }); + expect(stored.archive.byteLength).toBe(source.byteLength); + }); + + it('rejects undeclared files before storage', async () => { + const source = await archive(manifest(), zip => { + zip.file('payload.css', '.button { display: none }'); + }); + await expect(parser.parse(source)).rejects.toThrow('Undeclared archive file: payload.css'); + }); + + it('rejects unknown schemas', async () => { + const unsupported = { ...manifest(), schema: 'example.unknown' }; + await expect(parser.parse(await archive(unsupported))).rejects.toThrow('Schema must be bitfun.appearance'); + }); + + it('imports validated background video metadata and its poster', async () => { + const videoManifest: AppearancePackage = { + ...manifest(), + requiredCapabilities: ['assets.v1', 'background-media.v1'], + assets: { + motion: { + kind: 'video', + mimeType: 'video/webm', + source: { kind: 'package', path: 'assets/background.webm' }, + }, + poster: { + kind: 'image', + mimeType: 'image/png', + source: { kind: 'package', path: 'assets/poster.png' }, + }, + }, + backgroundMedia: { kind: 'video', assetId: 'motion', posterAssetId: 'poster' }, + components: undefined, + }; + const zip = new JSZip(); + zip.file('appearance.json', JSON.stringify(videoManifest)); + zip.file('assets/background.webm', webmHeader()); + zip.file('assets/poster.png', pngHeader(1920, 1080)); + const parser = new AppearancePackageParser( + createDefaultAppearanceRegistry(), + async () => ({ width: 1920, height: 1080, durationSeconds: 42 }), + ); + + const stored = await parser.parse(await zip.generateAsync({ type: 'arraybuffer' })); + expect(stored.assets.motion).toMatchObject({ + mimeType: 'video/webm', + width: 1920, + height: 1080, + durationSeconds: 42, + }); + }); + + it('rejects oversized expanded entries before JSZip materializes them', async () => { + const source = new Uint8Array(await archive(manifest())); + const view = new DataView(source.buffer); + for (let offset = 0; offset + 46 <= source.length; offset += 1) { + if (view.getUint32(offset, true) === 0x02014b50) { + view.setUint32(offset + 24, 65 * 1024 * 1024, true); + break; + } + } + await expect(parser.parse(source.buffer)).rejects.toThrow('Appearance archive entry is too large'); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.ts b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.ts new file mode 100644 index 0000000000..f683a27677 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.ts @@ -0,0 +1,369 @@ +import JSZip from 'jszip'; +import type { AppearanceRegistry } from '../registry/AppearanceRegistry'; +import { assertValidAppearancePackage } from './AppearancePackageValidator'; +import type { + AppearancePackageAsset, + StoredAppearanceAsset, + StoredAppearancePackage, +} from '../types'; + +const MAX_ARCHIVE_BYTES = 96 * 1024 * 1024; +const MAX_EXPANDED_BYTES = 128 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 256 * 1024; +const MAX_IMAGE_BYTES = 16 * 1024 * 1024; +const MAX_VIDEO_BYTES = 64 * 1024 * 1024; +const MAX_PREVIEW_BYTES = 4 * 1024 * 1024; +const MAX_ENTRIES = 64; +const MAX_DIMENSION = 16_384; +const MAX_PIXELS = 50_000_000; +const MAX_VIDEO_DIMENSION = 4_096; +const MAX_VIDEO_PIXELS = 9_000_000; +const MAX_VIDEO_DURATION_SECONDS = 60; +const MANIFEST_PATH = 'appearance.json'; +const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50; +const ZIP_CENTRAL_DIRECTORY_ENTRY = 0x02014b50; + +interface ImageInfo { + mimeType: Extract['mimeType']; + width: number; + height: number; +} + +export interface AppearanceVideoMetadata { + width: number; + height: number; + durationSeconds: number; +} + +export type AppearanceVideoMetadataReader = ( + bytes: Uint8Array, + mimeType: Extract['mimeType'], +) => Promise; + +export async function readAppearanceVideoMetadata( + bytes: Uint8Array, + mimeType: Extract['mimeType'], +): Promise { + const url = URL.createObjectURL(new Blob([bytes], { type: mimeType })); + const video = document.createElement('video'); + video.preload = 'metadata'; + video.muted = true; + try { + return await new Promise((resolve, reject) => { + let timeout = 0; + const finish = (callback: () => void): void => { + window.clearTimeout(timeout); + video.onloadedmetadata = null; + video.onerror = null; + callback(); + }; + timeout = window.setTimeout( + () => finish(() => reject(new Error('Appearance video metadata timed out'))), + 10_000, + ); + video.onloadedmetadata = () => finish(() => resolve({ + width: video.videoWidth, + height: video.videoHeight, + durationSeconds: video.duration, + })); + video.onerror = () => finish(() => reject(new Error('Appearance video format or codec is unsupported'))); + video.src = url; + video.load(); + }); + } finally { + video.removeAttribute('src'); + video.load(); + URL.revokeObjectURL(url); + } +} + +export class AppearancePackageParser { + constructor( + private readonly registry: AppearanceRegistry, + private readonly videoMetadataReader: AppearanceVideoMetadataReader = readAppearanceVideoMetadata, + ) {} + + async parse(source: ArrayBuffer): Promise { + if (source.byteLength === 0 || source.byteLength > MAX_ARCHIVE_BYTES) { + throw new Error(`Appearance archive must be between 1 byte and ${MAX_ARCHIVE_BYTES} bytes`); + } + this.assertSafeZipMetadata(source); + const zip = await JSZip.loadAsync(source, { checkCRC32: true }); + const files = Object.values(zip.files).filter(file => !file.dir); + if (files.length === 0 || files.length > MAX_ENTRIES) { + throw new Error(`Appearance archive must contain between 1 and ${MAX_ENTRIES} files`); + } + files.forEach(file => { + const metadata = file as { unsafeOriginalName?: string; unixPermissions?: number | string }; + this.assertSafeArchivePath(file.name, metadata.unsafeOriginalName); + const permissions = typeof metadata.unixPermissions === 'string' + ? parseInt(metadata.unixPermissions, 8) + : metadata.unixPermissions; + if (typeof permissions === 'number' && (permissions & 0o170000) === 0o120000) { + throw new Error(`Symbolic links are not allowed in appearance archives: ${file.name}`); + } + }); + + const manifestFile = zip.file(MANIFEST_PATH); + if (!manifestFile) throw new Error(`Appearance archive must contain ${MANIFEST_PATH}`); + const manifestBytes = await manifestFile.async('uint8array'); + if (manifestBytes.byteLength > MAX_MANIFEST_BYTES) throw new Error('Appearance manifest is too large'); + + let rawManifest: unknown; + try { + rawManifest = JSON.parse(new TextDecoder().decode(manifestBytes)); + } catch (error) { + throw new Error(`Appearance manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + const manifest = assertValidAppearancePackage(rawManifest, this.registry); + const declaredPaths = new Map(); + Object.entries(manifest.assets ?? {}).forEach(([id, definition]) => { + const path = definition.source.path; + if (declaredPaths.has(path)) throw new Error(`Appearance assets cannot share package path: ${path}`); + declaredPaths.set(path, { id, definition }); + }); + + const allowedPaths = new Set([MANIFEST_PATH, ...declaredPaths.keys()]); + files.forEach(file => { + if (!allowedPaths.has(file.name)) throw new Error(`Undeclared archive file: ${file.name}`); + }); + declaredPaths.forEach((_, path) => { + if (!zip.file(path)) throw new Error(`Declared appearance asset is missing: ${path}`); + }); + + const assets: Record = {}; + let expandedBytes = manifestBytes.byteLength; + const previewAssetId = manifest.preview?.assetId; + for (const [path, { id, definition }] of declaredPaths) { + const bytes = await zip.file(path)!.async('uint8array'); + expandedBytes += bytes.byteLength; + const maxAssetBytes = definition.kind === 'video' ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES; + if (bytes.byteLength === 0 || bytes.byteLength > maxAssetBytes) { + throw new Error(`Appearance asset has invalid size: ${path}`); + } + if (id === previewAssetId && bytes.byteLength > MAX_PREVIEW_BYTES) { + throw new Error(`Appearance preview asset exceeds the allowed size: ${path}`); + } + if (expandedBytes > MAX_EXPANDED_BYTES) throw new Error('Appearance archive expands beyond the allowed size'); + const metadata = definition.kind === 'image' + ? this.validateImage(path, bytes, definition.mimeType) + : await this.validateVideo(path, bytes, definition.mimeType); + const expectedDigest = manifest.integrity?.sha256[id]; + if (expectedDigest) { + const actualDigest = await this.sha256(bytes); + if (actualDigest !== expectedDigest.toLowerCase()) throw new Error(`Appearance asset integrity mismatch: ${path}`); + } + assets[id] = { + mimeType: definition.mimeType, + bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer, + ...metadata, + }; + } + + return { + manifest, + archive: source.slice(0), + assets, + importedAt: new Date().toISOString(), + }; + } + + private assertSafeZipMetadata(source: ArrayBuffer): void { + const view = new DataView(source); + const minimumOffset = Math.max(0, source.byteLength - 65_557); + let endOffset = -1; + for (let offset = source.byteLength - 22; offset >= minimumOffset; offset -= 1) { + if (view.getUint32(offset, true) === ZIP_END_OF_CENTRAL_DIRECTORY) { + endOffset = offset; + break; + } + } + if (endOffset < 0) throw new Error('Appearance archive central directory is missing'); + const diskNumber = view.getUint16(endOffset + 4, true); + const centralDisk = view.getUint16(endOffset + 6, true); + const entryCount = view.getUint16(endOffset + 10, true); + const centralSize = view.getUint32(endOffset + 12, true); + const centralOffset = view.getUint32(endOffset + 16, true); + if (diskNumber !== 0 || centralDisk !== 0 || entryCount === 0xffff + || centralSize === 0xffffffff || centralOffset === 0xffffffff) { + throw new Error('Multi-disk and ZIP64 appearance archives are not supported'); + } + if (centralOffset + centralSize > endOffset) throw new Error('Appearance archive central directory is invalid'); + + let offset = centralOffset; + let fileCount = 0; + let expandedBytes = 0; + for (let index = 0; index < entryCount; index += 1) { + if (offset + 46 > source.byteLength || view.getUint32(offset, true) !== ZIP_CENTRAL_DIRECTORY_ENTRY) { + throw new Error('Appearance archive central directory entry is invalid'); + } + const flags = view.getUint16(offset + 8, true); + const compressedSize = view.getUint32(offset + 20, true); + const uncompressedSize = view.getUint32(offset + 24, true); + const fileNameLength = view.getUint16(offset + 28, true); + const extraLength = view.getUint16(offset + 30, true); + const commentLength = view.getUint16(offset + 32, true); + const entryLength = 46 + fileNameLength + extraLength + commentLength; + if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff) { + throw new Error('ZIP64 appearance entries are not supported'); + } + if ((flags & 0x0001) !== 0) throw new Error('Encrypted appearance archive entries are not supported'); + if (offset + entryLength > source.byteLength) throw new Error('Appearance archive central directory entry is truncated'); + const nameBytes = new Uint8Array(source, offset + 46, fileNameLength); + const name = new TextDecoder((flags & 0x0800) !== 0 ? 'utf-8' : 'windows-1252').decode(nameBytes); + if (!name.endsWith('/')) { + fileCount += 1; + expandedBytes += uncompressedSize; + if (uncompressedSize > MAX_VIDEO_BYTES) throw new Error(`Appearance archive entry is too large: ${name}`); + if (expandedBytes > MAX_EXPANDED_BYTES) throw new Error('Appearance archive expands beyond the allowed size'); + } + offset += entryLength; + } + if (fileCount === 0 || fileCount > MAX_ENTRIES) { + throw new Error(`Appearance archive must contain between 1 and ${MAX_ENTRIES} files`); + } + } + + private validateImage( + path: string, + bytes: Uint8Array, + expectedMimeType: Extract['mimeType'], + ): Pick { + const image = this.readImageInfo(bytes); + if (image.mimeType !== expectedMimeType) throw new Error(`Appearance asset MIME mismatch: ${path}`); + if (image.width <= 0 || image.height <= 0 + || image.width > MAX_DIMENSION || image.height > MAX_DIMENSION + || image.width * image.height > MAX_PIXELS) { + throw new Error(`Appearance image dimensions exceed the allowed limit: ${path}`); + } + return { width: image.width, height: image.height }; + } + + private async validateVideo( + path: string, + bytes: Uint8Array, + expectedMimeType: Extract['mimeType'], + ): Promise> { + const actualMimeType = this.readVideoMimeType(bytes); + if (actualMimeType !== expectedMimeType) throw new Error(`Appearance asset MIME mismatch: ${path}`); + const metadata = await this.videoMetadataReader(bytes, expectedMimeType); + if (!Number.isFinite(metadata.durationSeconds) || metadata.durationSeconds <= 0 + || metadata.durationSeconds > MAX_VIDEO_DURATION_SECONDS) { + throw new Error(`Appearance video duration exceeds the allowed limit: ${path}`); + } + if (!Number.isFinite(metadata.width) || !Number.isFinite(metadata.height) + || metadata.width <= 0 || metadata.height <= 0 + || metadata.width > MAX_VIDEO_DIMENSION || metadata.height > MAX_VIDEO_DIMENSION + || metadata.width * metadata.height > MAX_VIDEO_PIXELS) { + throw new Error(`Appearance video dimensions exceed the allowed limit: ${path}`); + } + return { + width: metadata.width, + height: metadata.height, + durationSeconds: metadata.durationSeconds, + }; + } + + private assertSafeArchivePath(path: string, unsafeOriginalName?: string): void { + if (unsafeOriginalName && unsafeOriginalName !== path) throw new Error(`Unsafe archive path: ${unsafeOriginalName}`); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_./-]*$/.test(path) || path.includes('..') || path.includes('\\') || path.startsWith('/')) { + throw new Error(`Unsafe archive path: ${path}`); + } + } + + private readImageInfo(bytes: Uint8Array): ImageInfo { + if (bytes.length >= 24 && this.ascii(bytes, 1, 3) === 'PNG' && bytes[0] === 0x89) { + return { mimeType: 'image/png', width: this.u32be(bytes, 16), height: this.u32be(bytes, 20) }; + } + if (bytes.length >= 10 && (this.ascii(bytes, 0, 6) === 'GIF87a' || this.ascii(bytes, 0, 6) === 'GIF89a')) { + return { mimeType: 'image/gif', width: this.u16le(bytes, 6), height: this.u16le(bytes, 8) }; + } + if (bytes.length >= 12 && this.ascii(bytes, 0, 4) === 'RIFF' && this.ascii(bytes, 8, 4) === 'WEBP') { + return this.readWebpInfo(bytes); + } + if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) { + return this.readJpegInfo(bytes); + } + throw new Error('Appearance asset is not a supported image'); + } + + private readVideoMimeType(bytes: Uint8Array): 'video/mp4' | 'video/webm' { + if (bytes.length >= 12 && this.ascii(bytes, 4, 4) === 'ftyp') return 'video/mp4'; + if (bytes.length >= 16 + && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3 + && this.containsAscii(bytes, 'webm', 4_096)) { + return 'video/webm'; + } + throw new Error('Appearance asset is not a supported video'); + } + + private containsAscii(bytes: Uint8Array, expected: string, maxBytes: number): boolean { + const limit = Math.min(bytes.length, maxBytes) - expected.length; + for (let offset = 0; offset <= limit; offset += 1) { + if (this.ascii(bytes, offset, expected.length).toLowerCase() === expected) return true; + } + return false; + } + + private readJpegInfo(bytes: Uint8Array): ImageInfo { + let offset = 2; + while (offset + 8 < bytes.length) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = bytes[offset + 1]; + if (marker === 0xd8 || marker === 0xd9) { + offset += 2; + continue; + } + const length = this.u16be(bytes, offset + 2); + if (length < 2 || offset + 2 + length > bytes.length) break; + if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) { + return { mimeType: 'image/jpeg', width: this.u16be(bytes, offset + 7), height: this.u16be(bytes, offset + 5) }; + } + offset += 2 + length; + } + throw new Error('Appearance JPEG dimensions could not be read'); + } + + private readWebpInfo(bytes: Uint8Array): ImageInfo { + const chunk = this.ascii(bytes, 12, 4); + if (chunk === 'VP8X' && bytes.length >= 30) { + return { + mimeType: 'image/webp', + width: 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), + height: 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16), + }; + } + if (chunk === 'VP8 ' && bytes.length >= 30) { + return { mimeType: 'image/webp', width: this.u16le(bytes, 26) & 0x3fff, height: this.u16le(bytes, 28) & 0x3fff }; + } + if (chunk === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) { + const bits = bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24); + return { mimeType: 'image/webp', width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }; + } + throw new Error('Appearance WebP dimensions could not be read'); + } + + private async sha256(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, '0')).join(''); + } + + private ascii(bytes: Uint8Array, offset: number, length: number): string { + return String.fromCharCode(...bytes.slice(offset, offset + length)); + } + + private u16be(bytes: Uint8Array, offset: number): number { + return (bytes[offset] << 8) | bytes[offset + 1]; + } + + private u16le(bytes: Uint8Array, offset: number): number { + return bytes[offset] | (bytes[offset + 1] << 8); + } + + private u32be(bytes: Uint8Array, offset: number): number { + return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0; + } +} diff --git a/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidationError.ts b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidationError.ts new file mode 100644 index 0000000000..c0e66efea4 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidationError.ts @@ -0,0 +1,108 @@ +import type { AppearanceValidationIssue } from '../types'; + +export interface AppearanceValidationIssueGroup { + key: string; + surfaceKind?: 'component' | 'scene'; + surfaceId?: string; + section: string; + issues: readonly AppearanceValidationIssue[]; + allowedParts: readonly string[]; +} + +function inferIssueLocation(issue: AppearanceValidationIssue): { + surfaceKind?: 'component' | 'scene'; + surfaceId?: string; + section: string; +} { + if (issue.context?.surfaceKind && issue.context.surfaceId) { + return { + surfaceKind: issue.context.surfaceKind, + surfaceId: issue.context.surfaceId, + section: issue.context.surfaceKind === 'component' ? 'components' : 'scenes', + }; + } + const [section, surfaceId] = issue.path.split('.'); + if ((section === 'components' || section === 'scenes') && surfaceId) { + return { + surfaceKind: section === 'components' ? 'component' : 'scene', + surfaceId, + section, + }; + } + return { section: section && section !== '$' ? section : 'package' }; +} + +export function groupAppearanceValidationIssues( + issues: readonly AppearanceValidationIssue[], +): AppearanceValidationIssueGroup[] { + const groups = new Map; + }>(); + + issues.forEach(issue => { + const location = inferIssueLocation(issue); + const key = location.surfaceKind && location.surfaceId + ? `${location.surfaceKind}:${location.surfaceId}` + : `section:${location.section}`; + const group = groups.get(key) ?? { + ...location, + issues: [], + allowedParts: new Set(), + }; + group.issues.push(issue); + issue.context?.allowedParts?.forEach(part => group.allowedParts.add(part)); + groups.set(key, group); + }); + + return [...groups.entries()].map(([key, group]) => Object.freeze({ + key, + surfaceKind: group.surfaceKind, + surfaceId: group.surfaceId, + section: group.section, + issues: Object.freeze([...group.issues]), + allowedParts: Object.freeze([...group.allowedParts]), + })); +} + +function formatValidationMessage(issues: readonly AppearanceValidationIssue[]): string { + const groups = groupAppearanceValidationIssues(issues); + const details = groups.map(group => { + const label = group.surfaceKind && group.surfaceId + ? `${group.surfaceKind} ${group.surfaceId}` + : group.section; + return `${label}:\n${group.issues.map(issue => ` - [${issue.code}] ${issue.path}: ${issue.message}`).join('\n')}`; + }).join('\n'); + return `Appearance package validation failed with ${issues.length} issue${issues.length === 1 ? '' : 's'}:\n${details}`; +} + +export class AppearancePackageValidationError extends Error { + readonly code = 'APPEARANCE_PACKAGE_VALIDATION_FAILED'; + readonly issues: readonly AppearanceValidationIssue[]; + readonly groups: readonly AppearanceValidationIssueGroup[]; + + constructor(issues: readonly AppearanceValidationIssue[]) { + super(formatValidationMessage(issues)); + this.name = 'AppearancePackageValidationError'; + this.issues = Object.freeze(issues.map(issue => { + const context = issue.context ? Object.freeze({ + ...issue.context, + ...(issue.context.allowedParts + ? { allowedParts: Object.freeze([...issue.context.allowedParts]) } + : {}), + }) : undefined; + return Object.freeze({ + ...issue, + ...(context ? { context } : {}), + }); + })); + this.groups = Object.freeze(groupAppearanceValidationIssues(this.issues)); + } +} + +export function getAppearancePackageValidationError(error: unknown): AppearancePackageValidationError | null { + return error instanceof AppearancePackageValidationError ? error : null; +} diff --git a/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidator.test.ts b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidator.test.ts new file mode 100644 index 0000000000..7a1e53f0c6 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidator.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, it } from 'vitest'; +import { createDefaultAppearanceRegistry } from '../registry/defaultAppearanceRegistry'; +import type { AppearancePackage } from '../types'; +import { AppearancePackageValidationError } from './AppearancePackageValidationError'; +import { appearancePackageValidator, assertValidAppearancePackage } from './AppearancePackageValidator'; + +function validPackage(): AppearancePackage { + return { + schema: 'bitfun.appearance', + schemaVersion: 1, + id: 'test.appearance', + name: 'Test Appearance', + version: '1.0.0', + mode: 'dark', + components: { + button: { + parts: { + root: { + base: { + backgroundColor: { kind: 'hex', value: '#101010' }, + color: { kind: 'hex', value: '#ffffff' }, + }, + }, + }, + }, + }, + }; +} + +describe('AppearancePackageValidator', () => { + const registry = createDefaultAppearanceRegistry(); + + it('accepts structured appearance data registered by the host', () => { + const result = appearancePackageValidator.validate(validPackage(), registry); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('rejects missing, mistyped, and circular token references before compilation', () => { + const raw = validPackage(); + raw.globals = { + colors: { + first: { kind: 'ref', path: 'globals.colors.second' }, + second: { kind: 'ref', path: 'globals.colors.first' }, + }, + lengths: { + gap: { kind: 'px', value: 8 }, + }, + }; + raw.components = { + button: { + parts: { + root: { + base: { + color: { kind: 'ref', path: 'globals.colors.missing' }, + backgroundColor: { kind: 'ref', path: 'globals.lengths.gap' }, + }, + }, + }, + }, + }; + + const result = appearancePackageValidator.validate(raw, registry); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'UNKNOWN_TOKEN_REFERENCE' }), + expect.objectContaining({ code: 'REFERENCE_TYPE_MISMATCH' }), + expect.objectContaining({ code: 'CIRCULAR_TOKEN_REFERENCE' }), + ])); + }); + + it('rejects unknown package schemas', () => { + const result = appearancePackageValidator.validate({ + ...validPackage(), + schema: 'example.unknown', + }, registry); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'INVALID_SCHEMA', path: 'schema' }), + ])); + }); + + it('rejects raw CSS strings and unregistered parts', () => { + const raw = validPackage() as unknown as Record; + const components = raw.components as Record }>; + components.button.parts.root = { base: { backgroundColor: 'url(https://example.com/a.png)' } }; + components.button.parts.internalClass = { base: { color: { kind: 'hex', value: '#fff' } } }; + + const result = appearancePackageValidator.validate(raw, registry); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'INVALID_COLOR' }), + expect.objectContaining({ code: 'UNKNOWN_PART' }), + ])); + }); + + it('rejects unregistered component ids instead of compiling package selectors', () => { + const raw = validPackage(); + raw.components = { + 'private-widget': { parts: { root: { base: { opacity: { kind: 'number', value: 1 } } } } }, + }; + const result = appearancePackageValidator.validate(raw, registry); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'UNKNOWN_SURFACE', path: 'components.private-widget' }), + ])); + }); + + it('keeps incompatible component contracts as grouped structured diagnostics', () => { + const raw = validPackage(); + raw.components = { + 'toolbar-mode': { + parts: { + sessionMenu: { base: { opacity: { kind: 'number', value: 1 } } }, + input: { base: { opacity: { kind: 'number', value: 1 } } }, + }, + }, + 'floating-mini-chat': { + parts: { + sessionMenu: { base: { opacity: { kind: 'number', value: 1 } } }, + inputBar: { base: { opacity: { kind: 'number', value: 1 } } }, + input: { base: { opacity: { kind: 'number', value: 1 } } }, + }, + }, + } as AppearancePackage['components']; + + const result = appearancePackageValidator.validate(raw, registry); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(5); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'UNKNOWN_PART', + path: 'components.toolbar-mode.parts.sessionMenu', + context: expect.objectContaining({ + surfaceKind: 'component', + surfaceId: 'toolbar-mode', + partId: 'sessionMenu', + allowedParts: expect.arrayContaining(['root', 'sessionSurface', 'controls']), + }), + }), + expect.objectContaining({ + code: 'UNKNOWN_PART', + path: 'components.floating-mini-chat.parts.inputBar', + context: expect.objectContaining({ + surfaceId: 'floating-mini-chat', + partId: 'inputBar', + allowedParts: expect.arrayContaining(['root', 'panel', 'body']), + }), + }), + ])); + + try { + assertValidAppearancePackage(raw, registry); + throw new Error('Expected appearance validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(AppearancePackageValidationError); + const validationError = error as AppearancePackageValidationError; + expect(validationError.groups).toHaveLength(2); + expect(validationError.groups.map(group => group.surfaceId)).toEqual([ + 'toolbar-mode', + 'floating-mini-chat', + ]); + expect(validationError.message).toContain('5 issues'); + expect(validationError.message).toContain('\ncomponent toolbar-mode:\n'); + } + }); + + it('rejects unknown fields instead of silently carrying CSS or selectors', () => { + const raw = validPackage() as unknown as Record; + raw.css = '.btn { display: none }'; + const components = raw.components as Record> }>; + components.button.parts.root.selector = '.private-class'; + const result = appearancePackageValidator.validate(raw, registry); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'UNKNOWN_FIELD', path: 'css' }), + expect.objectContaining({ code: 'UNKNOWN_FIELD', path: 'components.button.parts.root.selector' }), + ])); + }); + + it('accepts structured advanced style values and rejects behavior-changing CSS escape hatches', () => { + const structured = validPackage(); + structured.components = { + 'content-canvas': { + parts: { + root: { + base: { + display: 'grid', + gridTemplateColumns: { + kind: 'gridTracks', + tracks: [{ kind: 'fr', value: 1 }, { kind: 'px', value: 20 }], + }, + position: 'relative', + aspectRatio: { kind: 'ratio', width: 4, height: 3 }, + objectFit: 'contain', + filter: [{ kind: 'brightness', value: { kind: 'number', value: 1.1 } }], + }, + }, + }, + }, + }; + expect(appearancePackageValidator.validate(structured, registry).valid).toBe(true); + + const escaped = validPackage() as unknown as Record; + escaped.components = { + 'content-canvas': { + parts: { + root: { + base: { + display: 'none', + pointerEvents: 'none', + gridTemplateColumns: '1fr 1fr', + }, + }, + }, + }, + }; + const result = appearancePackageValidator.validate(escaped, registry); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'INVALID_ENUM_VALUE', path: 'components.content-canvas.parts.root.base.display' }), + expect.objectContaining({ code: 'UNSUPPORTED_STYLE_PROPERTY', path: 'components.content-canvas.parts.root.base.pointerEvents' }), + expect.objectContaining({ code: 'INVALID_GRID_TEMPLATE', path: 'components.content-canvas.parts.root.base.gridTemplateColumns' }), + ])); + }); + + it('accepts aligned structured background layers', () => { + const pkg = validPackage(); + pkg.assets = { + backdrop: { kind: 'image', mimeType: 'image/webp', source: { kind: 'package', path: 'assets/backdrop.webp' } }, + ornament: { kind: 'image', mimeType: 'image/png', source: { kind: 'package', path: 'assets/ornament.png' } }, + }; + pkg.components!.button.parts.root.base = { + backgroundImages: [ + { kind: 'asset', assetId: 'ornament' }, + { kind: 'asset', assetId: 'backdrop' }, + ], + backgroundSizes: [ + { kind: 'backgroundSize', width: { kind: 'px', value: 96 }, height: { kind: 'lengthKeyword', value: 'auto' } }, + 'cover', + ], + backgroundPositions: [ + { kind: 'backgroundPosition', x: { kind: 'percent', value: 100 }, y: { kind: 'percent', value: 100 } }, + 'center', + ], + backgroundRepeats: ['no-repeat', 'no-repeat'], + backgroundBlendModes: ['normal', 'soft-light'], + }; + + const result = appearancePackageValidator.validate(pkg, registry); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('accepts a declared preview asset and rejects missing preview references', () => { + const pkg = validPackage(); + pkg.assets = { + preview: { kind: 'image', mimeType: 'image/webp', source: { kind: 'package', path: 'assets/preview.webp' } }, + }; + pkg.preview = { kind: 'asset', assetId: 'preview' }; + expect(appearancePackageValidator.validate(pkg, registry).valid).toBe(true); + + pkg.preview = { kind: 'asset', assetId: 'missing' }; + expect(appearancePackageValidator.validate(pkg, registry).errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'UNKNOWN_ASSET_REFERENCE', path: 'preview.assetId' }), + ])); + }); + + it('accepts host-managed video background media with an image poster', () => { + const pkg = validPackage(); + pkg.requiredCapabilities = ['assets.v1', 'background-media.v1']; + pkg.assets = { + motion: { kind: 'video', mimeType: 'video/webm', source: { kind: 'package', path: 'assets/background.webm' } }, + poster: { kind: 'image', mimeType: 'image/webp', source: { kind: 'package', path: 'assets/poster.webp' } }, + }; + pkg.backgroundMedia = { + kind: 'video', + assetId: 'motion', + posterAssetId: 'poster', + fit: 'cover', + position: 'center', + }; + + expect(appearancePackageValidator.validate(pkg, registry)).toMatchObject({ valid: true, errors: [] }); + }); + + it('keeps video assets out of previews and CSS background declarations', () => { + const pkg = validPackage(); + pkg.assets = { + motion: { kind: 'video', mimeType: 'video/mp4', source: { kind: 'package', path: 'assets/background.mp4' } }, + }; + pkg.preview = { kind: 'asset', assetId: 'motion' }; + pkg.components!.button.parts.root.base = { + backgroundImage: { kind: 'asset', assetId: 'motion' }, + }; + + expect(appearancePackageValidator.validate(pkg, registry).errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'PREVIEW_ASSET_NOT_IMAGE', path: 'preview.assetId' }), + expect.objectContaining({ code: 'BACKGROUND_ASSET_NOT_IMAGE' }), + expect.objectContaining({ code: 'UNUSED_VIDEO_ASSET', path: 'assets.motion' }), + ])); + }); + + it('requires the background capability and rejects unrelated video assets', () => { + const pkg = validPackage(); + pkg.assets = { + motion: { kind: 'video', mimeType: 'video/webm', source: { kind: 'package', path: 'assets/background.webm' } }, + unused: { kind: 'video', mimeType: 'video/mp4', source: { kind: 'package', path: 'assets/unused.mp4' } }, + poster: { kind: 'image', mimeType: 'image/webp', source: { kind: 'package', path: 'assets/poster.webp' } }, + }; + pkg.backgroundMedia = { kind: 'video', assetId: 'motion', posterAssetId: 'poster' }; + + expect(appearancePackageValidator.validate(pkg, registry).errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MISSING_BACKGROUND_MEDIA_CAPABILITY', path: 'requiredCapabilities' }), + expect.objectContaining({ code: 'UNUSED_VIDEO_ASSET', path: 'assets.unused' }), + ])); + }); + + it('rejects conflicting or misaligned background layer declarations', () => { + const pkg = validPackage() as unknown as Record; + const components = pkg.components as Record }> }>; + components.button.parts.root.base = { + backgroundImage: { kind: 'asset', assetId: 'backdrop' }, + backgroundImages: [ + { kind: 'asset', assetId: 'backdrop' }, + { kind: 'asset', assetId: 'ornament' }, + ], + backgroundSizes: ['cover'], + backgroundBlendModes: ['normal', 'unsupported'], + }; + + const result = appearancePackageValidator.validate(pkg, registry); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CONFLICTING_BACKGROUND_FIELDS' }), + expect.objectContaining({ code: 'BACKGROUND_LAYER_COUNT_MISMATCH' }), + expect.objectContaining({ code: 'INVALID_BACKGROUND_BLEND_MODES' }), + expect.objectContaining({ code: 'UNKNOWN_ASSET_REFERENCE' }), + ])); + }); + + it('validates composable material definitions and rejects the retired singular field', () => { + const pkg = validPackage() as unknown as Record; + pkg.materials = { + surface: { + visualRole: 'control', + style: { backgroundColor: { kind: 'hex', value: '#202020' } }, + }, + }; + const components = pkg.components as Record> }>; + components.button.parts.root.materials = ['surface']; + expect(appearancePackageValidator.validate(pkg, registry).valid).toBe(true); + + delete components.button.parts.root.materials; + components.button.parts.root.material = 'surface'; + expect(appearancePackageValidator.validate(pkg, registry).errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'UNKNOWN_FIELD', path: 'components.button.parts.root.material' }), + ])); + }); + + it('warns when continuous regions are framed without an explicit intent', () => { + const pkg = validPackage(); + pkg.components = { + 'content-canvas': { + parts: { + main: { base: { borderRadius: { kind: 'px', value: 8 } } }, + }, + }, + }; + + const warned = appearancePackageValidator.validate(pkg, registry); + expect(warned.valid).toBe(true); + expect(warned.warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CONTINUOUS_SURFACE_FRAMED' }), + ])); + + pkg.components['content-canvas'].parts.main.decorationIntent = 'framed'; + const intentional = appearancePackageValidator.validate(pkg, registry); + expect(intentional.warnings).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CONTINUOUS_SURFACE_FRAMED' }), + ])); + + pkg.components['content-canvas'].parts.main = { + states: { active: { boxShadow: [{ kind: 'ref', path: 'globals.shadows.surface' }] } }, + }; + pkg.globals = { + shadows: { + surface: { + kind: 'shadow', + x: { kind: 'zero' }, + y: { kind: 'px', value: 4 }, + blur: { kind: 'px', value: 12 }, + color: { kind: 'rgb', r: 0, g: 0, b: 0, a: 0.2 }, + }, + }, + }; + expect(appearancePackageValidator.validate(pkg, registry).warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CONTINUOUS_SURFACE_FRAMED', path: 'components.content-canvas.parts.main.states.active' }), + ])); + }); + + it('warns when override is used as a package-wide default', () => { + const pkg = validPackage(); + pkg.components = { + 'content-canvas': { + parts: { + root: { cascade: 'override', base: { color: { kind: 'hex', value: '#ffffff' } } }, + main: { cascade: 'override', base: { color: { kind: 'hex', value: '#ffffff' } } }, + editor: { cascade: 'override', base: { color: { kind: 'hex', value: '#ffffff' } } }, + empty: { cascade: 'normal', base: { color: { kind: 'hex', value: '#ffffff' } } }, + }, + }, + }; + + const result = appearancePackageValidator.validate(pkg, registry); + expect(result.valid).toBe(true); + expect(result.warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'EXCESSIVE_OVERRIDE_USAGE' }), + ])); + }); +}); diff --git a/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidator.ts b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidator.ts new file mode 100644 index 0000000000..40751e113f --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageValidator.ts @@ -0,0 +1,1303 @@ +import type { AppearanceRegistry } from '../registry/AppearanceRegistry'; +import { + APPEARANCE_SCHEMA, + APPEARANCE_SCHEMA_VERSION, + type AppearancePackage, + type AppearanceStyle, + type AppearanceStyleProperty, + type AppearanceSurfaceDefinition, + type AppearanceSurfaceDescriptor, + type AppearanceValidationIssue, + type AppearanceValidationResult, +} from '../types'; +import { getAppearanceProfileProperties } from '../appearancePropertyProfiles'; +import { AppearancePackageValidationError } from './AppearancePackageValidationError'; + +type ValidationErrorReporter = ( + path: string, + code: string, + message: string, + context?: AppearanceValidationIssue['context'], +) => void; + +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/; +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const REFERENCE_PATTERN = /^globals\.(colors|lengths|numbers|durations|easings|fontFamilies|shadows)\.[a-z][a-zA-Z0-9.-]*$/; +type AppearanceTokenGroup = 'colors' | 'lengths' | 'numbers' | 'durations' | 'easings' | 'fontFamilies' | 'shadows'; +const FORBIDDEN_TEXT_PATTERN = /(?:https?:\/\/|javascript:|data:|url\s*\(|<\/?[a-z]|[{};])/i; +const VISUAL_ROLES = new Set([ + 'workspace', 'continuous-surface', 'panel', 'toolbar', 'card', 'control', + 'popup', 'dialog', 'divider', 'content', 'decoration', +]); + +const STYLE_PROPERTIES = new Set([ + 'backgroundColor', 'backgroundImage', 'color', 'caretColor', 'accentColor', + 'textDecorationColor', 'borderColor', 'borderTopColor', + 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', + 'backgroundImages', 'backgroundSizes', 'backgroundPositions', + 'backgroundRepeats', 'backgroundBlendModes', + 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderWidth', + 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', + 'borderStyle', 'borderRadius', 'borderTopLeftRadius', 'borderTopRightRadius', + 'borderBottomRightRadius', 'borderBottomLeftRadius', 'boxSizing', + 'outlineColor', 'outlineWidth', 'outlineOffset', 'outlineStyle', + 'boxShadow', 'opacity', 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', + 'fontStyle', 'fontVariantNumeric', 'letterSpacing', 'textAlign', 'verticalAlign', + 'textIndent', 'textDecoration', + 'textTransform', 'textOverflow', 'whiteSpace', 'wordBreak', 'overflowWrap', 'display', + 'flexDirection', 'flexWrap', 'flexGrow', 'flexShrink', 'flexBasis', 'alignItems', + 'alignContent', 'alignSelf', 'justifyContent', 'justifySelf', 'justifyItems', + 'placeItems', 'placeContent', 'order', 'gridTemplateColumns', + 'gridTemplateRows', 'gridAutoFlow', 'gridColumnSpan', 'gridRowSpan', + 'gap', 'rowGap', 'columnGap', + 'width', 'minWidth', 'maxWidth', 'height', 'minHeight', 'maxHeight', 'padding', + 'paddingBlock', 'paddingInline', 'paddingTop', 'paddingRight', 'paddingBottom', + 'paddingLeft', 'margin', 'marginBlock', 'marginInline', 'marginTop', 'marginRight', + 'marginBottom', 'marginLeft', 'position', 'insetBlock', 'insetInline', 'top', + 'right', 'bottom', 'left', 'zIndex', 'aspectRatio', + 'overflow', 'overflowX', 'overflowY', 'cursor', 'transition', 'transform', + 'filter', 'backdropBlur', 'objectFit', 'objectPosition', 'mixBlendMode', 'isolation', +]); + +const COLOR_PROPERTIES = new Set([ + 'backgroundColor', 'color', 'caretColor', 'accentColor', 'textDecorationColor', + 'borderColor', 'borderTopColor', 'borderRightColor', 'borderBottomColor', + 'borderLeftColor', 'outlineColor', +]); +const LENGTH_PROPERTIES = new Set([ + 'borderWidth', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', + 'borderLeftWidth', 'borderRadius', 'outlineWidth', 'outlineOffset', 'fontSize', + 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', + 'borderBottomLeftRadius', + 'letterSpacing', 'textIndent', 'gap', 'rowGap', 'columnGap', 'width', 'minWidth', 'maxWidth', + 'height', 'minHeight', 'maxHeight', 'padding', 'paddingBlock', 'paddingInline', + 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'margin', + 'marginBlock', 'marginInline', 'marginTop', 'marginRight', 'marginBottom', + 'marginLeft', 'flexBasis', 'insetBlock', 'insetInline', 'top', 'right', 'bottom', + 'left', 'backdropBlur', +]); +const NUMBER_PROPERTIES = new Set([ + 'opacity', 'fontWeight', 'lineHeight', 'flexGrow', 'flexShrink', 'gridColumnSpan', + 'gridRowSpan', 'zIndex', 'order', +]); + +const ENUM_VALUES: Partial>> = { + borderStyle: new Set(['none', 'solid', 'dashed', 'dotted']), + outlineStyle: new Set(['none', 'solid', 'dashed', 'dotted']), + backgroundSize: new Set(['auto', 'cover', 'contain']), + backgroundPosition: new Set(['center', 'top', 'right', 'bottom', 'left']), + backgroundRepeat: new Set(['repeat', 'repeat-x', 'repeat-y', 'no-repeat']), + boxSizing: new Set(['content-box', 'border-box']), + fontStyle: new Set(['normal', 'italic']), + fontVariantNumeric: new Set(['normal', 'tabular-nums']), + textAlign: new Set(['left', 'center', 'right', 'start', 'end']), + verticalAlign: new Set(['baseline', 'sub', 'super', 'text-top', 'text-bottom', 'middle', 'top', 'bottom']), + textDecoration: new Set(['none', 'underline', 'line-through']), + textTransform: new Set(['none', 'uppercase', 'lowercase', 'capitalize']), + textOverflow: new Set(['clip', 'ellipsis']), + whiteSpace: new Set(['normal', 'nowrap', 'pre', 'pre-wrap', 'pre-line']), + wordBreak: new Set(['normal', 'break-all', 'keep-all', 'break-word']), + overflowWrap: new Set(['normal', 'break-word', 'anywhere']), + display: new Set(['block', 'inline', 'inline-block', 'flex', 'inline-flex', 'grid']), + flexDirection: new Set(['row', 'row-reverse', 'column', 'column-reverse']), + flexWrap: new Set(['nowrap', 'wrap', 'wrap-reverse']), + alignItems: new Set(['stretch', 'flex-start', 'center', 'flex-end', 'baseline']), + alignContent: new Set(['stretch', 'flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly']), + alignSelf: new Set(['auto', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline']), + justifyContent: new Set(['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly']), + justifySelf: new Set(['auto', 'stretch', 'start', 'center', 'end']), + justifyItems: new Set(['auto', 'normal', 'stretch', 'start', 'center', 'end']), + placeItems: new Set(['normal', 'stretch', 'start', 'center', 'end']), + placeContent: new Set(['normal', 'stretch', 'start', 'center', 'end', 'space-between', 'space-around', 'space-evenly']), + gridAutoFlow: new Set(['row', 'column', 'dense', 'row dense', 'column dense']), + position: new Set(['static', 'relative', 'absolute', 'fixed', 'sticky']), + overflow: new Set(['visible', 'hidden', 'clip', 'auto', 'scroll']), + overflowX: new Set(['visible', 'hidden', 'clip', 'auto', 'scroll']), + overflowY: new Set(['visible', 'hidden', 'clip', 'auto', 'scroll']), + cursor: new Set(['default', 'pointer', 'text', 'move', 'grab', 'grabbing', 'wait', 'not-allowed']), + objectFit: new Set(['fill', 'contain', 'cover', 'none', 'scale-down']), + objectPosition: new Set(['center', 'top', 'right', 'bottom', 'left']), + mixBlendMode: new Set(['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'soft-light', 'hard-light']), + isolation: new Set(['auto', 'isolate']), +}; + +const BACKGROUND_SIZE_VALUES = new Set(['auto', 'cover', 'contain']); +const BACKGROUND_POSITION_VALUES = new Set(['center', 'top', 'right', 'bottom', 'left']); +const BACKGROUND_REPEAT_VALUES = new Set(['repeat', 'repeat-x', 'repeat-y', 'no-repeat']); +const BACKGROUND_BLEND_MODE_VALUES = new Set([ + 'normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'soft-light', 'hard-light', +]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function finiteNumber(value: unknown, min: number, max: number): boolean { + return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max; +} + +export class AppearancePackageValidator { + validate(input: unknown, registry: AppearanceRegistry): AppearanceValidationResult { + const errors: AppearanceValidationIssue[] = []; + const warnings: AppearanceValidationIssue[] = []; + const error: ValidationErrorReporter = (path, code, message, context) => errors.push({ + path, + code, + message, + ...(context ? { context } : {}), + }); + const warning = (path: string, code: string, message: string) => warnings.push({ path, code, message }); + + if (!isRecord(input)) { + error('$', 'INVALID_PACKAGE', 'Appearance package must be an object'); + return { valid: false, errors, warnings }; + } + this.validateKnownKeys(input, [ + 'schema', 'schemaVersion', 'id', 'name', 'author', 'description', 'version', 'mode', + 'preview', 'backgroundMedia', 'requiredCapabilities', 'globals', 'materials', 'components', 'scenes', 'renderers', + 'assets', 'integrity', + ], '$', error); + + if (input.schema !== APPEARANCE_SCHEMA) { + error('schema', 'INVALID_SCHEMA', `Schema must be ${APPEARANCE_SCHEMA}`); + } + if (input.schemaVersion !== APPEARANCE_SCHEMA_VERSION) { + error('schemaVersion', 'UNSUPPORTED_SCHEMA_VERSION', `Schema version must be ${APPEARANCE_SCHEMA_VERSION}`); + } + this.validateId(input.id, 'id', error); + if (typeof input.name !== 'string' || input.name.trim().length === 0 || input.name.length > 100) { + error('name', 'INVALID_NAME', 'Name must be between 1 and 100 characters'); + } + for (const field of ['author', 'description'] as const) { + const value = input[field]; + if (value !== undefined && (typeof value !== 'string' || value.length > (field === 'author' ? 100 : 500))) { + error(field, `INVALID_${field.toUpperCase()}`, `Invalid ${field}`); + } + } + if (typeof input.version !== 'string' || !VERSION_PATTERN.test(input.version)) { + error('version', 'INVALID_VERSION', 'Version must use semantic version syntax'); + } + if (input.mode !== 'light' && input.mode !== 'dark') { + error('mode', 'INVALID_MODE', 'Mode must be light or dark'); + } + + if (input.requiredCapabilities !== undefined) { + if (!Array.isArray(input.requiredCapabilities)) { + error('requiredCapabilities', 'INVALID_CAPABILITIES', 'Required capabilities must be an array'); + } else { + const supported = new Set(['components.v1', 'scenes.v1', 'renderers.v1', 'assets.v1', 'background-media.v1']); + input.requiredCapabilities.forEach((capability, index) => { + if (typeof capability !== 'string' || !supported.has(capability)) { + error(`requiredCapabilities.${index}`, 'UNSUPPORTED_CAPABILITY', `Unsupported capability: ${String(capability)}`); + } + }); + } + } + + this.validateGlobals(input.globals, 'globals', error); + this.validateMaterials(input.materials, 'materials', error, warning); + this.validateSurfaces(input.components, 'components', id => registry.getComponent(id), input.materials, error, warning); + this.validateSurfaces(input.scenes, 'scenes', id => registry.getScene(id), input.materials, error, warning); + this.validateRenderers(input.renderers, registry, error); + this.validateAssets(input.assets, error); + this.validatePreview(input.preview, input.assets, error); + this.validateBackgroundMedia(input.backgroundMedia, input.assets, error); + this.validateVideoAssetUsage(input.backgroundMedia, input.assets, input.requiredCapabilities, error); + this.validateAssetReferences(input.materials, input.components, input.scenes, input.assets, error); + this.validateIntegrity(input.integrity, input.assets, error); + this.auditCascadeUsage(input.components, input.scenes, warning); + this.validateReferenceGraph(input, error); + + return { + valid: errors.length === 0, + errors, + warnings, + value: errors.length === 0 ? input as unknown as AppearancePackage : undefined, + }; + } + + private validatePreview( + value: unknown, + assets: unknown, + error: (path: string, code: string, message: string) => void, + ): void { + if (value === undefined) return; + if (!isRecord(value) || value.kind !== 'asset' || typeof value.assetId !== 'string') { + error('preview', 'INVALID_PREVIEW', 'Preview must reference a declared image asset'); + return; + } + if (!isRecord(assets) || !(value.assetId in assets)) { + error('preview.assetId', 'UNKNOWN_ASSET_REFERENCE', `Unknown appearance asset ${value.assetId}`); + return; + } + const asset = assets[value.assetId]; + if (!isRecord(asset) || asset.kind !== 'image') { + error('preview.assetId', 'PREVIEW_ASSET_NOT_IMAGE', 'Preview must reference an image asset'); + } + } + + private validateBackgroundMedia( + value: unknown, + assets: unknown, + error: (path: string, code: string, message: string) => void, + ): void { + if (value === undefined) return; + if (!isRecord(value) || value.kind !== 'video') { + error('backgroundMedia', 'INVALID_BACKGROUND_MEDIA', 'Background media must be a video declaration'); + return; + } + this.validateKnownKeys(value, ['kind', 'assetId', 'posterAssetId', 'fit', 'position'], 'backgroundMedia', error); + const validateAssetKind = (field: 'assetId' | 'posterAssetId', expectedKind: 'video' | 'image'): void => { + const assetId = value[field]; + if (typeof assetId !== 'string') { + error(`backgroundMedia.${field}`, 'INVALID_BACKGROUND_MEDIA_ASSET', `${field} must reference a declared ${expectedKind} asset`); + return; + } + if (!isRecord(assets) || !(assetId in assets)) { + error(`backgroundMedia.${field}`, 'UNKNOWN_ASSET_REFERENCE', `Unknown appearance asset ${assetId}`); + return; + } + const asset = assets[assetId]; + if (!isRecord(asset) || asset.kind !== expectedKind) { + error(`backgroundMedia.${field}`, 'BACKGROUND_MEDIA_ASSET_KIND_MISMATCH', `${field} must reference a ${expectedKind} asset`); + } + }; + validateAssetKind('assetId', 'video'); + validateAssetKind('posterAssetId', 'image'); + if (value.fit !== undefined && value.fit !== 'cover' && value.fit !== 'contain') { + error('backgroundMedia.fit', 'INVALID_BACKGROUND_MEDIA_FIT', 'Background media fit must be cover or contain'); + } + if (value.position !== undefined && !['center', 'top', 'right', 'bottom', 'left'].includes(String(value.position))) { + error('backgroundMedia.position', 'INVALID_BACKGROUND_MEDIA_POSITION', 'Background media position is invalid'); + } + } + + private validateVideoAssetUsage( + backgroundMedia: unknown, + assets: unknown, + requiredCapabilities: unknown, + error: (path: string, code: string, message: string) => void, + ): void { + const videoAssetIds = isRecord(assets) + ? Object.entries(assets) + .filter(([, asset]) => isRecord(asset) && asset.kind === 'video') + .map(([id]) => id) + : []; + if (backgroundMedia !== undefined + && (!Array.isArray(requiredCapabilities) || !requiredCapabilities.includes('background-media.v1'))) { + error( + 'requiredCapabilities', + 'MISSING_BACKGROUND_MEDIA_CAPABILITY', + 'Background media requires the background-media.v1 capability', + ); + } + const selectedVideoId = isRecord(backgroundMedia) && typeof backgroundMedia.assetId === 'string' + ? backgroundMedia.assetId + : undefined; + videoAssetIds.forEach(assetId => { + if (assetId !== selectedVideoId) { + error( + `assets.${assetId}`, + 'UNUSED_VIDEO_ASSET', + 'Video assets are allowed only when referenced by top-level backgroundMedia', + ); + } + }); + } + + private validateId( + value: unknown, + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + if (typeof value !== 'string' || value.length > 100 || !ID_PATTERN.test(value)) { + error(path, 'INVALID_ID', 'Id must be a lowercase dotted or dashed identifier'); + } + } + + private validateGlobals( + value: unknown, + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + if (value === undefined) return; + if (!isRecord(value)) { + error(path, 'INVALID_GLOBALS', 'Globals must be an object'); + return; + } + const validators: Record void) => void> = { + colors: this.validateColor.bind(this), + lengths: this.validateLength.bind(this), + numbers: this.validateNumber.bind(this), + durations: this.validateDuration.bind(this), + easings: this.validateEasing.bind(this), + fontFamilies: this.validateFontFamily.bind(this), + shadows: this.validateShadow.bind(this), + }; + Object.entries(value).forEach(([group, entries]) => { + const validator = validators[group]; + if (!validator) { + error(`${path}.${group}`, 'UNKNOWN_GLOBAL_GROUP', `Unknown global token group: ${group}`); + return; + } + if (!isRecord(entries)) { + error(`${path}.${group}`, 'INVALID_TOKEN_GROUP', 'Token group must be an object'); + return; + } + Object.entries(entries).forEach(([id, token]) => { + this.validateId(id, `${path}.${group}.${id}`, error); + validator(token, `${path}.${group}.${id}`, error); + }); + }); + } + + private validateMaterials( + value: unknown, + path: string, + error: (path: string, code: string, message: string) => void, + warning: (path: string, code: string, message: string) => void, + ): void { + if (value === undefined) return; + if (!isRecord(value)) { + error(path, 'INVALID_MATERIALS', 'Materials must be an object'); + return; + } + Object.entries(value).forEach(([id, definition]) => { + this.validateId(id, `${path}.${id}`, error); + if (!isRecord(definition) || !isRecord(definition.style)) { + error(`${path}.${id}`, 'INVALID_MATERIAL', 'Material must contain a style object'); + return; + } + this.validateKnownKeys(definition, ['style', 'visualRole'], `${path}.${id}`, error); + if (definition.visualRole !== undefined + && (typeof definition.visualRole !== 'string' || !VISUAL_ROLES.has(definition.visualRole))) { + error(`${path}.${id}.visualRole`, 'INVALID_VISUAL_ROLE', `Unknown visual role ${String(definition.visualRole)}`); + } + this.validateStyle(definition.style, `${path}.${id}.style`, undefined, error, warning); + }); + } + + private validateSurfaces( + value: unknown, + path: string, + getDescriptor: (id: string) => AppearanceSurfaceDescriptor | undefined, + materials: unknown, + error: ValidationErrorReporter, + warning: (path: string, code: string, message: string) => void, + ): void { + if (value === undefined) return; + if (!isRecord(value)) { + error(path, 'INVALID_SURFACES', 'Surface definitions must be an object'); + return; + } + Object.entries(value).forEach(([id, surface]) => { + const descriptor = getDescriptor(id); + if (!descriptor) { + error( + `${path}.${id}`, + 'UNKNOWN_SURFACE', + `No registered appearance contract for ${id}`, + { + surfaceKind: path === 'components' ? 'component' : 'scene', + surfaceId: id, + }, + ); + return; + } + this.validateSurface(surface, `${path}.${id}`, descriptor, materials, error, warning); + }); + } + + private validateSurface( + value: unknown, + path: string, + descriptor: AppearanceSurfaceDescriptor, + materials: unknown, + error: ValidationErrorReporter, + warning: (path: string, code: string, message: string) => void, + ): void { + if (!isRecord(value) || !isRecord(value.parts)) { + error(path, 'INVALID_SURFACE', 'Surface must contain a parts object'); + return; + } + this.validateKnownKeys(value, ['parts'], path, error); + const parts = new Map(descriptor.parts.map(part => [part.id, part])); + const facets = new Map((descriptor.facets ?? []).map(facet => [facet.id, facet])); + const states = new Set((descriptor.states ?? []).map(state => state.id)); + Object.entries(value.parts).forEach(([partId, rawRule]) => { + const part = parts.get(partId); + const partPath = `${path}.parts.${partId}`; + if (!part) { + error(partPath, 'UNKNOWN_PART', `Unknown part ${partId}`, { + surfaceKind: path.startsWith('components.') ? 'component' : 'scene', + surfaceId: descriptor.id, + partId, + allowedParts: descriptor.parts.map(candidate => candidate.id), + }); + return; + } + if (!isRecord(rawRule)) { + error(partPath, 'INVALID_PART_RULE', 'Part rule must be an object'); + return; + } + this.validateKnownKeys( + rawRule, + ['cascade', 'materials', 'decorationIntent', 'base', 'facets', 'states', 'contexts'], + partPath, + error, + ); + if (rawRule.cascade !== undefined && rawRule.cascade !== 'normal' && rawRule.cascade !== 'override') { + error(`${partPath}.cascade`, 'INVALID_CASCADE', 'Cascade must be normal or override'); + } + if (rawRule.decorationIntent !== undefined + && !['flat', 'separator', 'framed'].includes(String(rawRule.decorationIntent))) { + error(`${partPath}.decorationIntent`, 'INVALID_DECORATION_INTENT', 'Decoration intent must be flat, separator, or framed'); + } + const allowedProperties = part.allowedProperties + ?? getAppearanceProfileProperties(part.propertyProfile); + const materialStyles: Record[] = []; + if (rawRule.materials !== undefined) { + if (!Array.isArray(rawRule.materials) || rawRule.materials.length === 0 || rawRule.materials.length > 8) { + error(`${partPath}.materials`, 'INVALID_MATERIAL_LIST', 'Materials must contain between 1 and 8 material ids'); + } else { + rawRule.materials.forEach((materialId, index) => { + if (typeof materialId !== 'string' || !isRecord(materials) || !(materialId in materials)) { + error(`${partPath}.materials.${index}`, 'UNKNOWN_MATERIAL', `Unknown material ${String(materialId)}`); + return; + } + const definition = materials[materialId]; + if (isRecord(definition) && isRecord(definition.style)) { + materialStyles.push(definition.style); + this.validateStyle( + definition.style, + `${partPath}.materials.${index}`, + allowedProperties, + error, + warning, + ); + } + }); + } + } + this.validateStyle(rawRule.base, `${partPath}.base`, allowedProperties, error, warning); + const effectiveBase = Object.assign({}, ...materialStyles, isRecord(rawRule.base) ? rawRule.base : {}); + this.auditPartVisualSemantics(effectiveBase, rawRule, part, partPath, warning); + if (rawRule.facets !== undefined) { + if (!isRecord(rawRule.facets)) { + error(`${partPath}.facets`, 'INVALID_FACETS', 'Facets must be an object'); + } else { + Object.entries(rawRule.facets).forEach(([facetId, options]) => { + const facet = facets.get(facetId); + if (!facet) { + error(`${partPath}.facets.${facetId}`, 'UNKNOWN_FACET', `Unknown facet ${facetId}`); + return; + } + if (!isRecord(options)) { + error(`${partPath}.facets.${facetId}`, 'INVALID_FACET_OPTIONS', 'Facet options must be an object'); + return; + } + Object.entries(options).forEach(([option, style]) => { + const stylePath = `${partPath}.facets.${facetId}.${option}`; + if (!facet.values.includes(option)) { + error(stylePath, 'UNKNOWN_FACET_VALUE', `Unknown ${facetId} value ${option}`); + return; + } + this.validateStyle(style, stylePath, allowedProperties, error, warning); + if (isRecord(style)) this.auditPartVisualSemantics(style, rawRule, part, stylePath, warning); + }); + }); + } + } + if (rawRule.states !== undefined) { + if (!isRecord(rawRule.states)) { + error(`${partPath}.states`, 'INVALID_STATES', 'States must be an object'); + } else { + Object.entries(rawRule.states).forEach(([stateId, style]) => { + const stylePath = `${partPath}.states.${stateId}`; + if (!states.has(stateId)) { + error(stylePath, 'UNKNOWN_STATE', `Unknown state ${stateId}`); + return; + } + this.validateStyle(style, stylePath, allowedProperties, error, warning); + if (isRecord(style)) this.auditPartVisualSemantics(style, rawRule, part, stylePath, warning); + }); + } + } + if (rawRule.contexts !== undefined) { + if (!Array.isArray(rawRule.contexts)) { + error(`${partPath}.contexts`, 'INVALID_CONTEXTS', 'Contexts must be an array'); + } else { + rawRule.contexts.forEach((context, index) => { + const contextPath = `${partPath}.contexts.${index}`; + if (!isRecord(context) || !isRecord(context.when)) { + error(contextPath, 'INVALID_CONTEXT', 'Context must contain a when object'); + return; + } + this.validateKnownKeys(context, ['when', 'style'], contextPath, error); + this.validateKnownKeys(context.when, ['facets', 'states'], `${contextPath}.when`, error); + this.validateCondition(context.when, contextPath, facets, states, error); + this.validateStyle(context.style, `${contextPath}.style`, allowedProperties, error, warning); + if (isRecord(context.style)) { + this.auditPartVisualSemantics(context.style, rawRule, part, `${contextPath}.style`, warning); + } + }); + } + } + }); + } + + private validateCondition( + value: Record, + path: string, + facets: Map[number]>, + states: Set, + error: (path: string, code: string, message: string) => void, + ): void { + if (value.facets !== undefined) { + if (!isRecord(value.facets)) { + error(`${path}.when.facets`, 'INVALID_CONDITION_FACETS', 'Condition facets must be an object'); + } else { + Object.entries(value.facets).forEach(([facetId, option]) => { + const facet = facets.get(facetId); + if (!facet || typeof option !== 'string' || !facet.values.includes(option)) { + error(`${path}.when.facets.${facetId}`, 'INVALID_CONDITION_FACET', `Invalid facet condition ${facetId}=${String(option)}`); + } + }); + } + } + if (value.states !== undefined) { + if (!Array.isArray(value.states)) { + error(`${path}.when.states`, 'INVALID_CONDITION_STATES', 'Condition states must be an array'); + } else { + value.states.forEach((state, index) => { + if (typeof state !== 'string' || !states.has(state)) { + error(`${path}.when.states.${index}`, 'INVALID_CONDITION_STATE', `Invalid state condition ${String(state)}`); + } + }); + } + } + } + + private validateStyle( + value: unknown, + path: string, + allowedProperties: readonly AppearanceStyleProperty[] | undefined, + error: (path: string, code: string, message: string) => void, + warning?: (path: string, code: string, message: string) => void, + ): void { + if (value === undefined) return; + if (!isRecord(value)) { + error(path, 'INVALID_STYLE', 'Style must be an object'); + return; + } + const allowed = allowedProperties ? new Set(allowedProperties) : STYLE_PROPERTIES; + this.validateBackgroundLayerGroup(value, path, error); + if (value.borderStyle !== undefined && value.borderWidth === undefined) { + warning?.( + path, + 'BORDER_WIDTH_NORMALIZED', + 'Border style without a global width is normalized to zero before side widths are applied', + ); + } + if ((value.outlineWidth !== undefined || value.outlineColor !== undefined) + && value.outlineStyle === undefined) { + warning?.( + path, + 'OUTLINE_STYLE_NORMALIZED', + 'Outline width/color without a style is normalized to solid', + ); + } + Object.entries(value).forEach(([rawProperty, propertyValue]) => { + const property = rawProperty as AppearanceStyleProperty; + const propertyPath = `${path}.${rawProperty}`; + if (!STYLE_PROPERTIES.has(property) || !allowed.has(property)) { + error(propertyPath, 'UNSUPPORTED_STYLE_PROPERTY', `Style property ${rawProperty} is not allowed`); + return; + } + if (COLOR_PROPERTIES.has(property)) { + this.validateColor(propertyValue, propertyPath, error); + } else if (LENGTH_PROPERTIES.has(property)) { + this.validateLength(propertyValue, propertyPath, error); + } else if (NUMBER_PROPERTIES.has(property)) { + this.validateNumber(propertyValue, propertyPath, error); + } else if (property === 'backgroundImage') { + this.validateAssetReference(propertyValue, propertyPath, error); + } else if (property === 'backgroundImages') { + if (!Array.isArray(propertyValue) || propertyValue.length === 0 || propertyValue.length > 8) { + error(propertyPath, 'INVALID_BACKGROUND_IMAGES', 'Background images must contain between 1 and 8 package asset references'); + } else { + propertyValue.forEach((asset, index) => this.validateAssetReference(asset, `${propertyPath}.${index}`, error)); + } + } else if (property === 'backgroundSizes') { + if (!Array.isArray(propertyValue)) { + error(propertyPath, 'INVALID_BACKGROUND_SIZES', 'Background sizes must be an array'); + } else { + propertyValue.forEach((size, index) => this.validateBackgroundSize(size, `${propertyPath}.${index}`, error)); + } + } else if (property === 'backgroundPositions') { + if (!Array.isArray(propertyValue)) { + error(propertyPath, 'INVALID_BACKGROUND_POSITIONS', 'Background positions must be an array'); + } else { + propertyValue.forEach((position, index) => this.validateBackgroundPosition(position, `${propertyPath}.${index}`, error)); + } + } else if (property === 'backgroundRepeats') { + this.validateStringArray(propertyValue, propertyPath, BACKGROUND_REPEAT_VALUES, 'INVALID_BACKGROUND_REPEATS', error); + } else if (property === 'backgroundBlendModes') { + this.validateStringArray(propertyValue, propertyPath, BACKGROUND_BLEND_MODE_VALUES, 'INVALID_BACKGROUND_BLEND_MODES', error); + } else if (property === 'boxShadow') { + if (!Array.isArray(propertyValue)) { + error(propertyPath, 'INVALID_SHADOW_LIST', 'Box shadow must be an array'); + } else { + propertyValue.forEach((shadow, index) => this.validateShadow(shadow, `${propertyPath}.${index}`, error)); + } + } else if (property === 'fontFamily') { + this.validateFontFamily(propertyValue, propertyPath, error); + } else if (property === 'transition') { + this.validateTransition(propertyValue, propertyPath, error); + } else if (property === 'transform') { + this.validateTransform(propertyValue, propertyPath, error); + } else if (property === 'gridTemplateColumns' || property === 'gridTemplateRows') { + this.validateGridTemplate(propertyValue, propertyPath, error); + } else if (property === 'aspectRatio') { + this.validateRatio(propertyValue, propertyPath, error); + } else if (property === 'filter') { + this.validateFilter(propertyValue, propertyPath, error); + } else { + const values = ENUM_VALUES[property]; + if (!values?.has(String(propertyValue))) { + error(propertyPath, 'INVALID_ENUM_VALUE', `Invalid value for ${property}`); + } + } + }); + } + + private validateBackgroundLayerGroup( + style: Record, + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + const layeredKeys = [ + 'backgroundSizes', 'backgroundPositions', 'backgroundRepeats', 'backgroundBlendModes', + ] as const; + const singularKeys = ['backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat'] as const; + const images = style.backgroundImages; + + if (images === undefined) { + layeredKeys.forEach(key => { + if (style[key] !== undefined) { + error(`${path}.${key}`, 'BACKGROUND_LAYERS_REQUIRE_IMAGES', `${key} requires backgroundImages`); + } + }); + return; + } + + singularKeys.forEach(key => { + if (style[key] !== undefined) { + error(`${path}.${key}`, 'CONFLICTING_BACKGROUND_FIELDS', `${key} cannot be combined with backgroundImages`); + } + }); + + if (!Array.isArray(images)) return; + layeredKeys.forEach(key => { + const values = style[key]; + if (Array.isArray(values) && values.length !== images.length) { + error(`${path}.${key}`, 'BACKGROUND_LAYER_COUNT_MISMATCH', `${key} must contain exactly ${images.length} entries`); + } + }); + } + + private validateAssetReference( + value: unknown, + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + if (!isRecord(value) || value.kind !== 'asset' || typeof value.assetId !== 'string' || !ID_PATTERN.test(value.assetId)) { + error(path, 'INVALID_ASSET_REFERENCE', 'Background image must be a package asset reference'); + } + } + + private validateBackgroundSize( + value: unknown, + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + if (typeof value === 'string' && BACKGROUND_SIZE_VALUES.has(value)) return; + if (!isRecord(value) || value.kind !== 'backgroundSize') { + error(path, 'INVALID_BACKGROUND_SIZE', 'Background size must be auto, cover, contain, or a structured size'); + return; + } + this.validateKnownKeys(value, ['kind', 'width', 'height'], path, error); + this.validateBackgroundDimension(value.width, `${path}.width`, error); + if (value.height !== undefined) this.validateBackgroundDimension(value.height, `${path}.height`, error); + } + + private validateBackgroundPosition( + value: unknown, + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + if (typeof value === 'string' && BACKGROUND_POSITION_VALUES.has(value)) return; + if (!isRecord(value) || value.kind !== 'backgroundPosition') { + error(path, 'INVALID_BACKGROUND_POSITION', 'Background position must be a keyword or structured x/y position'); + return; + } + this.validateKnownKeys(value, ['kind', 'x', 'y'], path, error); + this.validateBackgroundPositionCoordinate(value.x, `${path}.x`, error); + this.validateBackgroundPositionCoordinate(value.y, `${path}.y`, error); + } + + private validateBackgroundDimension( + value: unknown, + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + if (this.validateReference(value, 'lengths', path, error)) return; + if (isRecord(value) && value.kind === 'zero') return; + if (isRecord(value) + && ['px', 'rem', 'em', 'ch', 'vw', 'vh', 'percent'].includes(String(value.kind)) + && finiteNumber(value.value, 0, 10000)) return; + if (isRecord(value) && value.kind === 'lengthKeyword' && value.value === 'auto') return; + error(path, 'INVALID_BACKGROUND_DIMENSION', 'Background dimensions must be non-negative lengths, auto, or references'); + } + + private validateBackgroundPositionCoordinate( + value: unknown, + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + if (this.validateReference(value, 'lengths', path, error)) return; + if (isRecord(value) && value.kind === 'zero') return; + if (isRecord(value) + && ['px', 'rem', 'em', 'ch', 'vw', 'vh', 'percent'].includes(String(value.kind)) + && finiteNumber(value.value, -10000, 10000)) return; + error(path, 'INVALID_BACKGROUND_POSITION_COORDINATE', 'Background position coordinates must be lengths or references'); + } + + private validateStringArray( + value: unknown, + path: string, + allowed: ReadonlySet, + code: string, + error: (path: string, code: string, message: string) => void, + ): void { + if (!Array.isArray(value)) { + error(path, code, 'Value must be an array'); + return; + } + value.forEach((item, index) => { + if (typeof item !== 'string' || !allowed.has(item)) { + error(`${path}.${index}`, code, `Unsupported value ${String(item)}`); + } + }); + } + + private validateReference( + value: unknown, + expectedGroup: AppearanceTokenGroup, + path: string, + error: (path: string, code: string, message: string) => void, + ): boolean { + if (!isRecord(value) || value.kind !== 'ref' || typeof value.path !== 'string') return false; + if (!REFERENCE_PATTERN.test(value.path)) return false; + if (!value.path.startsWith(`globals.${expectedGroup}.`)) { + error( + path, + 'REFERENCE_TYPE_MISMATCH', + `Reference must target globals.${expectedGroup}`, + ); + } + return true; + } + + private validateColor(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (this.validateReference(value, 'colors', path, error)) return; + if (!isRecord(value)) { + error(path, 'INVALID_COLOR', 'Color must be a structured color value'); + return; + } + if (value.kind === 'transparent') return; + if (value.kind === 'hex' && typeof value.value === 'string' && /^#[0-9a-f]{3,8}$/i.test(value.value) && [4, 5, 7, 9].includes(value.value.length)) return; + if (value.kind === 'rgb' && finiteNumber(value.r, 0, 255) && finiteNumber(value.g, 0, 255) && finiteNumber(value.b, 0, 255) && (value.a === undefined || finiteNumber(value.a, 0, 1))) return; + if (value.kind === 'hsl' && finiteNumber(value.h, 0, 360) && finiteNumber(value.s, 0, 100) && finiteNumber(value.l, 0, 100) && (value.a === undefined || finiteNumber(value.a, 0, 1))) return; + error(path, 'INVALID_COLOR', 'Invalid structured color value'); + } + + private validateLength(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (this.validateReference(value, 'lengths', path, error)) return; + if (isRecord(value) && value.kind === 'zero') return; + if (isRecord(value) && ['px', 'rem', 'em', 'ch', 'vw', 'vh', 'percent'].includes(String(value.kind)) && finiteNumber(value.value, -10000, 10000)) return; + if (isRecord(value) && value.kind === 'lengthKeyword' && ['auto', 'min-content', 'max-content', 'fit-content'].includes(String(value.value))) return; + error(path, 'INVALID_LENGTH', 'Length must be a structured unit, keyword, zero, or reference value'); + } + + private validateNumber(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (this.validateReference(value, 'numbers', path, error)) return; + if (isRecord(value) && value.kind === 'number' && finiteNumber(value.value, -10000, 10000)) return; + error(path, 'INVALID_NUMBER', 'Number must be a structured number or reference value'); + } + + private validateDuration(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (this.validateReference(value, 'durations', path, error)) return; + if (isRecord(value) && value.kind === 'ms' && finiteNumber(value.value, 0, 10000)) return; + error(path, 'INVALID_DURATION', 'Duration must be a structured millisecond or reference value'); + } + + private validateEasing(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (this.validateReference(value, 'easings', path, error)) return; + if (isRecord(value) && value.kind === 'easing' && ['linear', 'standard', 'decelerate', 'accelerate'].includes(String(value.value))) return; + error(path, 'INVALID_EASING', 'Easing must be a supported structured easing value'); + } + + private validateFontFamily(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (this.validateReference(value, 'fontFamilies', path, error)) return; + if (!isRecord(value) || value.kind !== 'fontFamily' || !Array.isArray(value.families) || value.families.length === 0 || value.families.length > 8) { + error(path, 'INVALID_FONT_FAMILY', 'Font family must contain between 1 and 8 local family names'); + return; + } + value.families.forEach((family, index) => { + if (typeof family !== 'string' || !/^[\w .-]{1,80}$/.test(family) || FORBIDDEN_TEXT_PATTERN.test(family)) { + error(`${path}.families.${index}`, 'INVALID_FONT_FAMILY_NAME', 'Invalid local font family name'); + } + }); + } + + private validateShadow(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (this.validateReference(value, 'shadows', path, error)) return; + if (isRecord(value) && value.kind === 'none') return; + if (!isRecord(value) || value.kind !== 'shadow') { + error(path, 'INVALID_SHADOW', 'Shadow must be a structured shadow, none, or reference value'); + return; + } + this.validateLength(value.x, `${path}.x`, error); + this.validateLength(value.y, `${path}.y`, error); + this.validateLength(value.blur, `${path}.blur`, error); + if (value.spread !== undefined) this.validateLength(value.spread, `${path}.spread`, error); + this.validateColor(value.color, `${path}.color`, error); + if (value.inset !== undefined && typeof value.inset !== 'boolean') { + error(`${path}.inset`, 'INVALID_SHADOW_INSET', 'Shadow inset must be boolean'); + } + } + + private validateTransition(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (!Array.isArray(value) || value.length > 12) { + error(path, 'INVALID_TRANSITION', 'Transition must be an array with at most 12 items'); + return; + } + const properties = new Set([ + 'all', 'background-color', 'background-position', 'border-color', 'border-radius', + 'box-shadow', 'color', 'filter', 'gap', 'height', 'margin', 'opacity', 'padding', + 'width', 'transform', + ]); + value.forEach((item, index) => { + const itemPath = `${path}.${index}`; + if (!isRecord(item) || !properties.has(String(item.property))) { + error(itemPath, 'INVALID_TRANSITION_ITEM', 'Transition item has an unsupported property'); + return; + } + this.validateDuration(item.duration, `${itemPath}.duration`, error); + this.validateEasing(item.easing, `${itemPath}.easing`, error); + if (item.delay !== undefined) this.validateDuration(item.delay, `${itemPath}.delay`, error); + }); + } + + private validateTransform(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (!isRecord(value) || value.kind !== 'transform') { + error(path, 'INVALID_TRANSFORM', 'Transform must be a structured transform value'); + return; + } + if (value.scale !== undefined && !finiteNumber(value.scale, 0, 10)) { + error(`${path}.scale`, 'INVALID_SCALE', 'Scale must be between 0 and 10'); + } + if (value.translateX !== undefined) this.validateLength(value.translateX, `${path}.translateX`, error); + if (value.translateY !== undefined) this.validateLength(value.translateY, `${path}.translateY`, error); + if (value.rotate !== undefined && !finiteNumber(value.rotate, -3600, 3600)) { + error(`${path}.rotate`, 'INVALID_ROTATION', 'Rotation must be between -3600 and 3600 degrees'); + } + } + + private validateGridTemplate(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (!isRecord(value) || value.kind !== 'gridTracks' || !Array.isArray(value.tracks) || value.tracks.length === 0 || value.tracks.length > 24) { + error(path, 'INVALID_GRID_TEMPLATE', 'Grid template must contain between 1 and 24 structured tracks'); + return; + } + value.tracks.forEach((track, index) => { + const trackPath = `${path}.tracks.${index}`; + if (isRecord(track) && track.kind === 'fr' && finiteNumber(track.value, 0.01, 100)) return; + if (isRecord(track) && track.kind === 'trackKeyword' && ['auto', 'min-content', 'max-content'].includes(String(track.value))) return; + this.validateLength(track, trackPath, error); + }); + } + + private validateRatio(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (!isRecord(value) + || value.kind !== 'ratio' + || !finiteNumber(value.width, 0.01, 10000) + || !finiteNumber(value.height, 0.01, 10000)) { + error(path, 'INVALID_ASPECT_RATIO', 'Aspect ratio must contain positive width and height values'); + } + } + + private validateFilter(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (!Array.isArray(value) || value.length > 8) { + error(path, 'INVALID_FILTER', 'Filter must be an array with at most 8 items'); + return; + } + value.forEach((item, index) => { + const itemPath = `${path}.${index}`; + if (!isRecord(item)) { + error(itemPath, 'INVALID_FILTER_ITEM', 'Filter item must be structured'); + return; + } + if (item.kind === 'blur') { + this.validateLength(item.value, `${itemPath}.value`, error); + return; + } + if (['brightness', 'contrast', 'grayscale', 'saturate'].includes(String(item.kind))) { + this.validateNumber(item.value, `${itemPath}.value`, error); + return; + } + if (item.kind === 'hueRotate' && finiteNumber(item.degrees, -3600, 3600)) return; + error(itemPath, 'INVALID_FILTER_ITEM', `Unsupported filter ${String(item.kind)}`); + }); + } + + private validateRenderers( + value: unknown, + registry: AppearanceRegistry, + error: (path: string, code: string, message: string) => void, + ): void { + if (value === undefined) return; + if (!isRecord(value)) { + error('renderers', 'INVALID_RENDERERS', 'Renderers must be an object'); + return; + } + Object.entries(value).forEach(([id, definition]) => { + const adapter = registry.getRenderer(id); + const path = `renderers.${id}`; + if (!adapter) { + error(path, 'UNKNOWN_RENDERER', `No registered renderer adapter for ${id}`); + return; + } + if (!isRecord(definition) || definition.version !== 1 || !isRecord(definition.settings)) { + error(path, 'INVALID_RENDERER_DEFINITION', 'Renderer definition must use version 1 and contain settings'); + return; + } + this.validateKnownKeys(definition, ['version', 'settings'], path, error); + this.scanRendererSettings(definition.settings, `${path}.settings`, error); + adapter.validate(definition.settings).forEach(message => { + error(`${path}.settings`, 'INVALID_RENDERER_SETTINGS', message); + }); + }); + } + + private scanRendererSettings(value: unknown, path: string, error: (path: string, code: string, message: string) => void): void { + if (typeof value === 'string') { + if (FORBIDDEN_TEXT_PATTERN.test(value)) { + error(path, 'FORBIDDEN_RENDERER_TEXT', 'Renderer settings cannot contain URLs, markup, or CSS syntax'); + } + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => this.scanRendererSettings(item, `${path}.${index}`, error)); + return; + } + if (isRecord(value)) { + Object.entries(value).forEach(([key, item]) => this.scanRendererSettings(item, `${path}.${key}`, error)); + } + } + + private validateAssets(value: unknown, error: (path: string, code: string, message: string) => void): void { + if (value === undefined) return; + if (!isRecord(value)) { + error('assets', 'INVALID_ASSETS', 'Assets must be an object'); + return; + } + Object.entries(value).forEach(([id, asset]) => { + const path = `assets.${id}`; + this.validateId(id, path, error); + const validImage = isRecord(asset) + && asset.kind === 'image' + && ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(String(asset.mimeType)); + const validVideo = isRecord(asset) + && asset.kind === 'video' + && ['video/mp4', 'video/webm'].includes(String(asset.mimeType)); + if (!validImage && !validVideo) { + error(path, 'INVALID_ASSET', 'Asset must be a supported image or video'); + return; + } + this.validateKnownKeys(asset, ['kind', 'mimeType', 'source'], path, error); + const source = asset.source; + if (!isRecord(source) || source.kind !== 'package' || typeof source.path !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_./-]*$/.test(source.path) || source.path.includes('..') || source.path.startsWith('/')) { + error(`${path}.source`, 'INVALID_ASSET_PATH', 'Asset path must be a safe package-relative path'); + } else { + this.validateKnownKeys(source, ['kind', 'path'], `${path}.source`, error); + } + }); + } + + private validateIntegrity( + value: unknown, + assets: unknown, + error: (path: string, code: string, message: string) => void, + ): void { + if (value === undefined) return; + if (!isRecord(value) || !isRecord(value.sha256)) { + error('integrity', 'INVALID_INTEGRITY', 'Integrity must contain a sha256 object'); + return; + } + Object.entries(value.sha256).forEach(([assetId, digest]) => { + if (!isRecord(assets) || !(assetId in assets)) { + error(`integrity.sha256.${assetId}`, 'UNKNOWN_INTEGRITY_ASSET', `Unknown integrity asset ${assetId}`); + } + if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/i.test(digest)) { + error(`integrity.sha256.${assetId}`, 'INVALID_SHA256', 'SHA-256 digest must contain 64 hexadecimal characters'); + } + }); + } + + private validateAssetReferences( + materials: unknown, + components: unknown, + scenes: unknown, + assets: unknown, + error: (path: string, code: string, message: string) => void, + ): void { + const declaredAssets = new Set(isRecord(assets) ? Object.keys(assets) : []); + const validateStyle = (style: unknown, path: string): void => { + if (!isRecord(style)) return; + const references: Array<{ value: unknown; path: string }> = []; + if (style.backgroundImage !== undefined) { + references.push({ value: style.backgroundImage, path: `${path}.backgroundImage` }); + } + if (Array.isArray(style.backgroundImages)) { + style.backgroundImages.forEach((value, index) => { + references.push({ value, path: `${path}.backgroundImages.${index}` }); + }); + } + references.forEach(reference => { + if (!isRecord(reference.value) + || reference.value.kind !== 'asset' + || typeof reference.value.assetId !== 'string') return; + const assetId = reference.value.assetId; + if (!declaredAssets.has(assetId)) { + error(reference.path, 'UNKNOWN_ASSET_REFERENCE', `Unknown appearance asset ${assetId}`); + return; + } + const referencedAsset = isRecord(assets) ? assets[assetId] : undefined; + if (isRecord(referencedAsset) && referencedAsset.kind !== 'image') { + error(reference.path, 'BACKGROUND_ASSET_NOT_IMAGE', 'Style background assets must be images'); + } + }); + }; + const validateSurfaces = (surfaces: unknown, path: string): void => { + if (!isRecord(surfaces)) return; + Object.entries(surfaces).forEach(([surfaceId, surface]) => { + if (!isRecord(surface) || !isRecord(surface.parts)) return; + Object.entries(surface.parts).forEach(([partId, rule]) => { + if (!isRecord(rule)) return; + const partPath = `${path}.${surfaceId}.parts.${partId}`; + validateStyle(rule.base, `${partPath}.base`); + if (isRecord(rule.facets)) { + Object.entries(rule.facets).forEach(([facetId, options]) => { + if (!isRecord(options)) return; + Object.entries(options).forEach(([option, style]) => { + validateStyle(style, `${partPath}.facets.${facetId}.${option}`); + }); + }); + } + if (isRecord(rule.states)) { + Object.entries(rule.states).forEach(([stateId, style]) => { + validateStyle(style, `${partPath}.states.${stateId}`); + }); + } + if (Array.isArray(rule.contexts)) { + rule.contexts.forEach((context, index) => { + if (isRecord(context)) validateStyle(context.style, `${partPath}.contexts.${index}.style`); + }); + } + }); + }); + }; + + if (isRecord(materials)) { + Object.entries(materials).forEach(([materialId, definition]) => { + if (isRecord(definition)) { + validateStyle(definition.style, `materials.${materialId}.style`); + } + }); + } + validateSurfaces(components, 'components'); + validateSurfaces(scenes, 'scenes'); + } + + private validateReferenceGraph( + pkg: Record, + error: (path: string, code: string, message: string) => void, + ): void { + const globals = isRecord(pkg.globals) ? pkg.globals : {}; + const tokenPaths = new Set(); + const references: Array<{ sourcePath: string; targetPath: string }> = []; + const graph = new Map>(); + + Object.entries(globals).forEach(([group, entries]) => { + if (!isRecord(entries)) return; + Object.keys(entries).forEach(id => tokenPaths.add(`globals.${group}.${id}`)); + }); + + const scan = (value: unknown, path: string, ownerToken?: string): void => { + if (Array.isArray(value)) { + value.forEach((item, index) => scan(item, `${path}.${index}`, ownerToken)); + return; + } + if (!isRecord(value)) return; + if (value.kind === 'ref' && typeof value.path === 'string' && REFERENCE_PATTERN.test(value.path)) { + references.push({ sourcePath: path, targetPath: value.path }); + if (ownerToken) { + const targets = graph.get(ownerToken) ?? new Set(); + targets.add(value.path); + graph.set(ownerToken, targets); + } + return; + } + Object.entries(value).forEach(([key, child]) => { + const childPath = path === '$' ? key : `${path}.${key}`; + scan(child, childPath, ownerToken); + }); + }; + + Object.entries(globals).forEach(([group, entries]) => { + if (!isRecord(entries)) return; + Object.entries(entries).forEach(([id, value]) => { + const tokenPath = `globals.${group}.${id}`; + scan(value, tokenPath, tokenPath); + }); + }); + Object.entries(pkg).forEach(([key, value]) => { + if (key !== 'globals') scan(value, key); + }); + + references.forEach(reference => { + if (!tokenPaths.has(reference.targetPath)) { + error( + reference.sourcePath, + 'UNKNOWN_TOKEN_REFERENCE', + `Unknown appearance token reference: ${reference.targetPath}`, + ); + } + }); + + const visited = new Set(); + const visiting = new Set(); + const visit = (tokenPath: string): void => { + if (visited.has(tokenPath)) return; + if (visiting.has(tokenPath)) { + error(tokenPath, 'CIRCULAR_TOKEN_REFERENCE', `Circular appearance token reference: ${tokenPath}`); + return; + } + visiting.add(tokenPath); + graph.get(tokenPath)?.forEach(targetPath => { + if (tokenPaths.has(targetPath)) visit(targetPath); + }); + visiting.delete(tokenPath); + visited.add(tokenPath); + }; + tokenPaths.forEach(visit); + } + + private auditPartVisualSemantics( + style: Record, + rule: Record, + part: AppearanceSurfaceDescriptor['parts'][number], + path: string, + warning: (path: string, code: string, message: string) => void, + ): void { + if ((!part.continuityGroup && part.visualRole !== 'continuous-surface') + || rule.decorationIntent === 'framed') return; + const hasFullFrame = this.hasNonZeroLength(style.borderWidth) + || [ + style.borderRadius, + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomRightRadius, + style.borderBottomLeftRadius, + ].some(value => this.hasNonZeroLength(value)) + || (Array.isArray(style.boxShadow) + && style.boxShadow.some(shadow => !isRecord(shadow) || shadow.kind !== 'none')); + if (!hasFullFrame) return; + warning( + path, + 'CONTINUOUS_SURFACE_FRAMED', + `Continuous surface${part.continuityGroup ? ` ${part.continuityGroup}` : ''} uses a full frame without decorationIntent=framed`, + ); + } + + private hasNonZeroLength(value: unknown): boolean { + if (!isRecord(value)) return value !== undefined; + if (value.kind === 'zero') return false; + return !(typeof value.value === 'number' && value.value === 0); + } + + private auditCascadeUsage( + components: unknown, + scenes: unknown, + warning: (path: string, code: string, message: string) => void, + ): void { + let total = 0; + let overrides = 0; + const inspect = (surfaces: unknown): void => { + if (!isRecord(surfaces)) return; + Object.values(surfaces).forEach(surface => { + if (!isRecord(surface) || !isRecord(surface.parts)) return; + Object.values(surface.parts).forEach(rule => { + if (!isRecord(rule)) return; + total += 1; + if (rule.cascade === 'override') overrides += 1; + }); + }); + }; + inspect(components); + inspect(scenes); + if (total >= 4 && overrides / total > 0.25) { + warning( + '$', + 'EXCESSIVE_OVERRIDE_USAGE', + `${overrides} of ${total} part rules use override; override should be reserved for specific paint conflicts`, + ); + } + } + + private validateKnownKeys( + value: Record, + allowedKeys: readonly string[], + path: string, + error: (path: string, code: string, message: string) => void, + ): void { + const allowed = new Set(allowedKeys); + Object.keys(value).forEach(key => { + if (!allowed.has(key)) { + error(path === '$' ? key : `${path}.${key}`, 'UNKNOWN_FIELD', `Unknown field: ${key}`); + } + }); + } +} + +export const appearancePackageValidator = new AppearancePackageValidator(); + +export function assertValidAppearancePackage( + input: unknown, + registry: AppearanceRegistry, +): AppearancePackage { + const result = appearancePackageValidator.validate(input, registry); + if (!result.valid || !result.value) { + throw new AppearancePackageValidationError(result.errors); + } + return result.value; +} + +export function isAppearanceSurfaceDefinition(value: unknown): value is AppearanceSurfaceDefinition { + return isRecord(value) && isRecord(value.parts); +} + +export function isAppearanceStyle(value: unknown): value is AppearanceStyle { + return isRecord(value) && Object.keys(value).every(key => STYLE_PROPERTIES.has(key as AppearanceStyleProperty)); +} diff --git a/src/web-ui/src/infrastructure/appearance/storage/AppearanceStorage.ts b/src/web-ui/src/infrastructure/appearance/storage/AppearanceStorage.ts new file mode 100644 index 0000000000..aa7f4ca147 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/storage/AppearanceStorage.ts @@ -0,0 +1,111 @@ +import type { + AppearanceStorage, + StoredAppearanceCatalogEntry, + StoredAppearancePackage, +} from '../types'; + +const DATABASE_NAME = 'bitfun-appearance'; +const DATABASE_VERSION = 1; +const PACKAGE_STORE = 'packages'; +const CATALOG_STORE = 'catalog'; + +function catalogEntry(value: StoredAppearancePackage): StoredAppearanceCatalogEntry { + return { + id: value.manifest.id, + name: value.manifest.name, + author: value.manifest.author, + description: value.manifest.description, + version: value.manifest.version, + mode: value.manifest.mode, + importedAt: value.importedAt, + }; +} + +export class MemoryAppearanceStorage implements AppearanceStorage { + private readonly packages = new Map(); + + async listCatalog(): Promise { + return [...this.packages.values()].map(catalogEntry); + } + + async get(id: string): Promise { + return this.packages.get(id) ?? null; + } + + async put(value: StoredAppearancePackage): Promise { + this.packages.set(value.manifest.id, value); + } + + async delete(id: string): Promise { + this.packages.delete(id); + } +} + +export class IndexedDbAppearanceStorage implements AppearanceStorage { + private databasePromise: Promise | null = null; + + private open(): Promise { + if (this.databasePromise) return this.databasePromise; + this.databasePromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(PACKAGE_STORE)) database.createObjectStore(PACKAGE_STORE); + if (!database.objectStoreNames.contains(CATALOG_STORE)) database.createObjectStore(CATALOG_STORE); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('Failed to open appearance storage')); + request.onblocked = () => reject(new Error('Appearance storage upgrade is blocked')); + }); + return this.databasePromise; + } + + private async read(storeName: string, key?: IDBValidKey): Promise { + const database = await this.open(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const request = key === undefined ? store.getAll() : store.get(key); + request.onsuccess = () => resolve(request.result as T); + request.onerror = () => reject(request.error ?? new Error('Appearance storage request failed')); + transaction.onabort = () => reject(transaction.error ?? new Error('Appearance storage transaction aborted')); + }); + } + + listCatalog(): Promise { + return this.read(CATALOG_STORE); + } + + async get(id: string): Promise { + return (await this.read(PACKAGE_STORE, id)) ?? null; + } + + async put(value: StoredAppearancePackage): Promise { + const database = await this.open(); + await new Promise((resolve, reject) => { + const transaction = database.transaction([PACKAGE_STORE, CATALOG_STORE], 'readwrite'); + transaction.objectStore(PACKAGE_STORE).put(value, value.manifest.id); + transaction.objectStore(CATALOG_STORE).put(catalogEntry(value), value.manifest.id); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error('Failed to store appearance package')); + transaction.onabort = () => reject(transaction.error ?? new Error('Appearance storage transaction aborted')); + }); + } + + async delete(id: string): Promise { + const database = await this.open(); + await new Promise((resolve, reject) => { + const transaction = database.transaction([PACKAGE_STORE, CATALOG_STORE], 'readwrite'); + transaction.objectStore(PACKAGE_STORE).delete(id); + transaction.objectStore(CATALOG_STORE).delete(id); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error('Failed to delete appearance package')); + transaction.onabort = () => reject(transaction.error ?? new Error('Appearance storage transaction aborted')); + }); + } + +} + +export function createAppearanceStorage(): AppearanceStorage { + return typeof indexedDB === 'undefined' ? new MemoryAppearanceStorage() : new IndexedDbAppearanceStorage(); +} diff --git a/src/web-ui/src/infrastructure/appearance/sync/AppearanceSync.ts b/src/web-ui/src/infrastructure/appearance/sync/AppearanceSync.ts new file mode 100644 index 0000000000..a6554b7f7b --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/sync/AppearanceSync.ts @@ -0,0 +1,82 @@ +import type { AppearanceSelectionId } from '../types'; + +export type AppearanceCommittedEvent = + | { + kind: 'selection-changed'; + eventId: string; + sourceId: string; + selectionId: AppearanceSelectionId; + } + | { + kind: 'package-upserted'; + eventId: string; + sourceId: string; + packageId: string; + version: string; + } + | { + kind: 'package-deleted'; + eventId: string; + sourceId: string; + packageId: string; + }; + +export interface AppearanceSyncPort { + publish(event: AppearanceCommittedEvent): void | Promise; + subscribe(listener: (event: AppearanceCommittedEvent) => void): () => void; + dispose?(): void; +} + +export const noopAppearanceSyncPort: AppearanceSyncPort = { + publish: () => undefined, + subscribe: () => () => undefined, +}; + +function isCommittedEvent(value: unknown): value is AppearanceCommittedEvent { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const event = value as Record; + if (typeof event.eventId !== 'string' || typeof event.sourceId !== 'string') return false; + if (event.kind === 'selection-changed') return typeof event.selectionId === 'string'; + if (event.kind === 'package-upserted') { + return typeof event.packageId === 'string' && typeof event.version === 'string'; + } + return event.kind === 'package-deleted' && typeof event.packageId === 'string'; +} + +class BroadcastChannelAppearanceSyncPort implements AppearanceSyncPort { + private readonly listeners = new Set<(event: AppearanceCommittedEvent) => void>(); + + constructor(private readonly channel: BroadcastChannel) { + channel.addEventListener('message', this.handleMessage); + } + + publish(event: AppearanceCommittedEvent): void { + this.channel.postMessage(event); + } + + subscribe(listener: (event: AppearanceCommittedEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + dispose(): void { + this.channel.removeEventListener('message', this.handleMessage); + this.channel.close(); + this.listeners.clear(); + } + + private readonly handleMessage = (message: MessageEvent): void => { + const event = message.data; + if (!isCommittedEvent(event)) return; + this.listeners.forEach(listener => listener(event)); + }; +} + +export function createAppearanceSyncPort(): AppearanceSyncPort { + if (typeof BroadcastChannel === 'undefined') return noopAppearanceSyncPort; + try { + return new BroadcastChannelAppearanceSyncPort(new BroadcastChannel('bitfun-appearance')); + } catch { + return noopAppearanceSyncPort; + } +} diff --git a/src/web-ui/src/infrastructure/appearance/types/index.ts b/src/web-ui/src/infrastructure/appearance/types/index.ts new file mode 100644 index 0000000000..561c799e08 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/types/index.ts @@ -0,0 +1,740 @@ +export const APPEARANCE_SCHEMA = 'bitfun.appearance' as const; +export const APPEARANCE_SCHEMA_VERSION = 1 as const; +export const SYSTEM_APPEARANCE_ID = 'system' as const; + +export type AppearanceMode = 'light' | 'dark'; +export type AppearanceSelectionId = string | typeof SYSTEM_APPEARANCE_ID; +export type AppearanceCapability = + | 'components.v1' + | 'scenes.v1' + | 'renderers.v1' + | 'assets.v1' + | 'background-media.v1'; + +export interface AppearanceReference { + kind: 'ref'; + path: string; +} + +export interface AppearanceHexColor { + kind: 'hex'; + value: string; +} + +export interface AppearanceRgbColor { + kind: 'rgb'; + r: number; + g: number; + b: number; + a?: number; +} + +export interface AppearanceHslColor { + kind: 'hsl'; + h: number; + s: number; + l: number; + a?: number; +} + +export interface AppearanceTransparentColor { + kind: 'transparent'; +} + +export type AppearanceColorValue = + | AppearanceReference + | AppearanceHexColor + | AppearanceRgbColor + | AppearanceHslColor + | AppearanceTransparentColor; + +export interface AppearanceLengthLiteral { + kind: 'px' | 'rem' | 'em' | 'ch' | 'vw' | 'vh' | 'percent'; + value: number; +} + +export interface AppearanceLengthKeyword { + kind: 'lengthKeyword'; + value: 'auto' | 'min-content' | 'max-content' | 'fit-content'; +} + +export interface AppearanceZeroLength { + kind: 'zero'; +} + +export type AppearanceLengthValue = AppearanceReference | AppearanceLengthLiteral | AppearanceLengthKeyword | AppearanceZeroLength; + +export interface AppearanceNumberLiteral { + kind: 'number'; + value: number; +} + +export type AppearanceNumberValue = AppearanceReference | AppearanceNumberLiteral; + +export interface AppearanceDurationLiteral { + kind: 'ms'; + value: number; +} + +export type AppearanceDurationValue = AppearanceReference | AppearanceDurationLiteral; + +export interface AppearanceFontFamilyLiteral { + kind: 'fontFamily'; + families: string[]; +} + +export type AppearanceFontFamilyValue = AppearanceReference | AppearanceFontFamilyLiteral; + +export interface AppearanceShadowLiteral { + kind: 'shadow'; + x: AppearanceLengthValue; + y: AppearanceLengthValue; + blur: AppearanceLengthValue; + spread?: AppearanceLengthValue; + color: AppearanceColorValue; + inset?: boolean; +} + +export interface AppearanceNoShadow { + kind: 'none'; +} + +export type AppearanceShadowValue = AppearanceReference | AppearanceShadowLiteral | AppearanceNoShadow; + +export type AppearanceEasingValue = + | AppearanceReference + | { kind: 'easing'; value: 'linear' | 'standard' | 'decelerate' | 'accelerate' }; + +export type AppearanceBorderStyle = 'none' | 'solid' | 'dashed' | 'dotted'; +export type AppearanceDisplay = 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid'; +export type AppearanceOverflow = 'visible' | 'hidden' | 'clip' | 'auto' | 'scroll'; +export type AppearanceCursor = 'default' | 'pointer' | 'text' | 'move' | 'grab' | 'grabbing' | 'wait' | 'not-allowed'; +export type AppearancePosition = 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'; +export type AppearanceGridTrackValue = + | AppearanceLengthValue + | { kind: 'fr'; value: number } + | { kind: 'trackKeyword'; value: 'auto' | 'min-content' | 'max-content' }; + +export interface AppearanceGridTemplateValue { + kind: 'gridTracks'; + tracks: AppearanceGridTrackValue[]; +} + +export interface AppearanceRatioValue { + kind: 'ratio'; + width: number; + height: number; +} + +export type AppearanceFilterItem = + | { kind: 'blur'; value: AppearanceLengthValue } + | { kind: 'brightness' | 'contrast' | 'grayscale' | 'saturate'; value: AppearanceNumberValue } + | { kind: 'hueRotate'; degrees: number }; + +export interface AppearanceTransitionItem { + property: + | 'all' + | 'background-color' + | 'background-position' + | 'border-color' + | 'border-radius' + | 'box-shadow' + | 'color' + | 'filter' + | 'gap' + | 'height' + | 'margin' + | 'opacity' + | 'padding' + | 'width' + | 'transform'; + duration: AppearanceDurationValue; + easing: AppearanceEasingValue; + delay?: AppearanceDurationValue; +} + +export interface AppearanceTransformValue { + kind: 'transform'; + scale?: number; + translateX?: AppearanceLengthValue; + translateY?: AppearanceLengthValue; + rotate?: number; +} + +export interface AppearanceAssetReference { + kind: 'asset'; + assetId: string; +} + +export type AppearanceBackgroundRepeat = 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat'; +export type AppearanceBlendMode = + | 'normal' + | 'multiply' + | 'screen' + | 'overlay' + | 'darken' + | 'lighten' + | 'soft-light' + | 'hard-light'; + +export type AppearanceBackgroundDimensionValue = + | AppearanceReference + | AppearanceLengthLiteral + | AppearanceZeroLength + | { kind: 'lengthKeyword'; value: 'auto' }; + +export type AppearanceBackgroundPositionCoordinate = + | AppearanceReference + | AppearanceLengthLiteral + | AppearanceZeroLength; + +export type AppearanceBackgroundSizeValue = + | 'auto' + | 'cover' + | 'contain' + | { + kind: 'backgroundSize'; + width: AppearanceBackgroundDimensionValue; + height?: AppearanceBackgroundDimensionValue; + }; + +export type AppearanceBackgroundPositionValue = + | 'center' + | 'top' + | 'right' + | 'bottom' + | 'left' + | { + kind: 'backgroundPosition'; + x: AppearanceBackgroundPositionCoordinate; + y: AppearanceBackgroundPositionCoordinate; + }; + +export interface AppearanceStyle { + backgroundColor?: AppearanceColorValue; + backgroundImage?: AppearanceAssetReference; + backgroundSize?: 'auto' | 'cover' | 'contain'; + backgroundPosition?: 'center' | 'top' | 'right' | 'bottom' | 'left'; + backgroundRepeat?: AppearanceBackgroundRepeat; + backgroundImages?: AppearanceAssetReference[]; + backgroundSizes?: AppearanceBackgroundSizeValue[]; + backgroundPositions?: AppearanceBackgroundPositionValue[]; + backgroundRepeats?: AppearanceBackgroundRepeat[]; + backgroundBlendModes?: AppearanceBlendMode[]; + color?: AppearanceColorValue; + caretColor?: AppearanceColorValue; + accentColor?: AppearanceColorValue; + textDecorationColor?: AppearanceColorValue; + borderColor?: AppearanceColorValue; + borderTopColor?: AppearanceColorValue; + borderRightColor?: AppearanceColorValue; + borderBottomColor?: AppearanceColorValue; + borderLeftColor?: AppearanceColorValue; + borderWidth?: AppearanceLengthValue; + borderTopWidth?: AppearanceLengthValue; + borderRightWidth?: AppearanceLengthValue; + borderBottomWidth?: AppearanceLengthValue; + borderLeftWidth?: AppearanceLengthValue; + borderStyle?: AppearanceBorderStyle; + borderRadius?: AppearanceLengthValue; + borderTopLeftRadius?: AppearanceLengthValue; + borderTopRightRadius?: AppearanceLengthValue; + borderBottomRightRadius?: AppearanceLengthValue; + borderBottomLeftRadius?: AppearanceLengthValue; + boxSizing?: 'content-box' | 'border-box'; + outlineColor?: AppearanceColorValue; + outlineWidth?: AppearanceLengthValue; + outlineOffset?: AppearanceLengthValue; + outlineStyle?: AppearanceBorderStyle; + boxShadow?: AppearanceShadowValue[]; + opacity?: AppearanceNumberValue; + fontFamily?: AppearanceFontFamilyValue; + fontSize?: AppearanceLengthValue; + fontWeight?: AppearanceNumberValue; + fontStyle?: 'normal' | 'italic'; + fontVariantNumeric?: 'normal' | 'tabular-nums'; + lineHeight?: AppearanceNumberValue; + letterSpacing?: AppearanceLengthValue; + textAlign?: 'left' | 'center' | 'right' | 'start' | 'end'; + verticalAlign?: 'baseline' | 'sub' | 'super' | 'text-top' | 'text-bottom' | 'middle' | 'top' | 'bottom'; + textIndent?: AppearanceLengthValue; + textDecoration?: 'none' | 'underline' | 'line-through'; + textTransform?: 'none' | 'uppercase' | 'lowercase' | 'capitalize'; + textOverflow?: 'clip' | 'ellipsis'; + whiteSpace?: 'normal' | 'nowrap' | 'pre' | 'pre-wrap' | 'pre-line'; + wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word'; + overflowWrap?: 'normal' | 'break-word' | 'anywhere'; + display?: AppearanceDisplay; + flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse'; + flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'; + flexGrow?: AppearanceNumberValue; + flexShrink?: AppearanceNumberValue; + flexBasis?: AppearanceLengthValue; + alignItems?: 'stretch' | 'flex-start' | 'center' | 'flex-end' | 'baseline'; + alignContent?: 'stretch' | 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'; + alignSelf?: 'auto' | 'stretch' | 'flex-start' | 'center' | 'flex-end' | 'baseline'; + justifyContent?: 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'; + justifySelf?: 'auto' | 'stretch' | 'start' | 'center' | 'end'; + justifyItems?: 'auto' | 'normal' | 'stretch' | 'start' | 'center' | 'end'; + placeItems?: 'normal' | 'stretch' | 'start' | 'center' | 'end'; + placeContent?: 'normal' | 'stretch' | 'start' | 'center' | 'end' | 'space-between' | 'space-around' | 'space-evenly'; + order?: AppearanceNumberValue; + gridTemplateColumns?: AppearanceGridTemplateValue; + gridTemplateRows?: AppearanceGridTemplateValue; + gridAutoFlow?: 'row' | 'column' | 'dense' | 'row dense' | 'column dense'; + gridColumnSpan?: AppearanceNumberValue; + gridRowSpan?: AppearanceNumberValue; + gap?: AppearanceLengthValue; + rowGap?: AppearanceLengthValue; + columnGap?: AppearanceLengthValue; + width?: AppearanceLengthValue; + minWidth?: AppearanceLengthValue; + maxWidth?: AppearanceLengthValue; + height?: AppearanceLengthValue; + minHeight?: AppearanceLengthValue; + maxHeight?: AppearanceLengthValue; + padding?: AppearanceLengthValue; + paddingBlock?: AppearanceLengthValue; + paddingInline?: AppearanceLengthValue; + paddingTop?: AppearanceLengthValue; + paddingRight?: AppearanceLengthValue; + paddingBottom?: AppearanceLengthValue; + paddingLeft?: AppearanceLengthValue; + margin?: AppearanceLengthValue; + marginBlock?: AppearanceLengthValue; + marginInline?: AppearanceLengthValue; + marginTop?: AppearanceLengthValue; + marginRight?: AppearanceLengthValue; + marginBottom?: AppearanceLengthValue; + marginLeft?: AppearanceLengthValue; + position?: AppearancePosition; + insetBlock?: AppearanceLengthValue; + insetInline?: AppearanceLengthValue; + top?: AppearanceLengthValue; + right?: AppearanceLengthValue; + bottom?: AppearanceLengthValue; + left?: AppearanceLengthValue; + zIndex?: AppearanceNumberValue; + aspectRatio?: AppearanceRatioValue; + overflow?: AppearanceOverflow; + overflowX?: AppearanceOverflow; + overflowY?: AppearanceOverflow; + cursor?: AppearanceCursor; + transition?: AppearanceTransitionItem[]; + transform?: AppearanceTransformValue; + filter?: AppearanceFilterItem[]; + backdropBlur?: AppearanceLengthValue; + objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'; + objectPosition?: 'center' | 'top' | 'right' | 'bottom' | 'left'; + mixBlendMode?: AppearanceBlendMode; + isolation?: 'auto' | 'isolate'; +} + +export type AppearanceStyleProperty = keyof AppearanceStyle; + +export type AppearancePropertyProfile = + | 'paint' + | 'control' + | 'container' + | 'layout' + | 'overlay'; + +export type AppearanceVisualRole = + | 'workspace' + | 'continuous-surface' + | 'panel' + | 'toolbar' + | 'card' + | 'control' + | 'popup' + | 'dialog' + | 'divider' + | 'content' + | 'decoration'; + +export type AppearanceDecorationIntent = 'flat' | 'separator' | 'framed'; + +export interface AppearanceGlobalTokens { + colors?: Record; + lengths?: Record; + numbers?: Record; + durations?: Record; + easings?: Record; + fontFamilies?: Record; + shadows?: Record; +} + +export interface AppearanceCondition { + facets?: Record; + states?: string[]; +} + +export interface AppearanceConditionalStyle { + when: AppearanceCondition; + style: AppearanceStyle; +} + +export interface AppearancePartRule { + cascade?: 'normal' | 'override'; + materials?: string[]; + decorationIntent?: AppearanceDecorationIntent; + base?: AppearanceStyle; + facets?: Record>; + states?: Record; + contexts?: AppearanceConditionalStyle[]; +} + +export interface AppearanceMaterialDefinition { + style: AppearanceStyle; + visualRole?: AppearanceVisualRole; +} + +export interface AppearanceSurfaceDefinition { + parts: Record; +} + +export type AppearanceRendererId = + | 'css-tokens' + | 'monaco' + | 'xterm' + | 'mermaid' + | 'generative-widget' + | 'bitfun-canvas'; + +export interface CssTokenAppearanceSettings { + tokens: Record; + background: string; +} + +export interface MonacoAppearanceTokenRule { + token: string; + foreground?: string; + background?: string; + fontStyle?: string; +} + +export interface MonacoAppearanceSettings { + id: string; + base: 'vs' | 'vs-dark' | 'hc-black' | 'hc-light'; + inherit: boolean; + rules: MonacoAppearanceTokenRule[]; + colors: Record; +} + +export type XtermAppearanceSurface = 'terminal' | 'output'; + +export interface XtermAppearanceColors { + background?: string; + foreground?: string; + cursor?: string; + cursorAccent?: string; + selectionBackground?: string; + selectionForeground?: string; + selectionInactiveBackground?: string; + black?: string; + red?: string; + green?: string; + yellow?: string; + blue?: string; + magenta?: string; + cyan?: string; + white?: string; + brightBlack?: string; + brightRed?: string; + brightGreen?: string; + brightYellow?: string; + brightBlue?: string; + brightMagenta?: string; + brightCyan?: string; + brightWhite?: string; +} + +export interface XtermAppearanceSettings { + surfaces: Record; + fontWeight: 'normal' | '500'; + fontWeightBold: 'bold' | '700'; +} + +export interface MermaidAppearancePalette { + nodeFill: string; + nodeFillHover: string; + nodeText: string; + nodeStroke: string; + nodeStrokeHover: string; + clusterFill: string; + clusterText: string; + clusterStroke: string; + edgeStroke: string; + edgeLabelBackground: string; + edgeLabelText: string; + noteFill: string; + noteText: string; + noteStroke: string; + activationFill: string; + activationStroke: string; + success: string; + warning: string; + error: string; + errorBackground: string; + info: string; + highlight: string; + pieColors: string[]; +} + +export interface MermaidAppearanceSettings { + mode: AppearanceMode; + palette: MermaidAppearancePalette; +} + +export interface WidgetAppearanceSettings { + id: string; + mode: AppearanceMode; + vars: Record; +} + +export interface CanvasAppearanceSettings { + id: string; + mode: AppearanceMode; + bg: string; + panel: string; + fg: string; + muted: string; + border: string; + accent: string; + success: string; + warning: string; + danger: string; + info: string; +} + +export interface AppearanceRendererSettingsMap { + 'css-tokens': CssTokenAppearanceSettings; + monaco: MonacoAppearanceSettings; + xterm: XtermAppearanceSettings; + mermaid: MermaidAppearanceSettings; + 'generative-widget': WidgetAppearanceSettings; + 'bitfun-canvas': CanvasAppearanceSettings; +} + +export type AppearanceRendererSettings = AppearanceRendererSettingsMap[AppearanceRendererId]; + +export type AppearanceRendererDefinitions = { + [K in AppearanceRendererId]?: { + version: 1; + settings: AppearanceRendererSettingsMap[K]; + }; +}; + +export interface AppearancePackageImageAsset { + kind: 'image'; + mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'; + source: { kind: 'package'; path: string }; +} + +export interface AppearancePackageVideoAsset { + kind: 'video'; + mimeType: 'video/mp4' | 'video/webm'; + source: { kind: 'package'; path: string }; +} + +export type AppearancePackageAsset = AppearancePackageImageAsset | AppearancePackageVideoAsset; + +export interface AppearanceBackgroundMedia { + kind: 'video'; + assetId: string; + posterAssetId: string; + fit?: 'cover' | 'contain'; + position?: 'center' | 'top' | 'right' | 'bottom' | 'left'; +} + +export interface AppearancePackage { + schema: typeof APPEARANCE_SCHEMA; + schemaVersion: typeof APPEARANCE_SCHEMA_VERSION; + id: string; + name: string; + author?: string; + description?: string; + version: string; + mode: AppearanceMode; + preview?: AppearanceAssetReference; + backgroundMedia?: AppearanceBackgroundMedia; + requiredCapabilities?: AppearanceCapability[]; + globals?: AppearanceGlobalTokens; + materials?: Record; + components?: Record; + scenes?: Record; + renderers?: AppearanceRendererDefinitions; + assets?: Record; + integrity?: { + sha256: Record; + }; +} + +export interface AppearancePartDescriptor { + id: string; + allowedProperties?: readonly AppearanceStyleProperty[]; + propertyProfile?: AppearancePropertyProfile; + forceableProperties?: readonly AppearanceStyleProperty[]; + visualRole?: AppearanceVisualRole; + continuityGroup?: string; +} + +export interface AppearanceFacetDescriptor { + id: string; + attribute: `data-bf-${string}`; + values: readonly string[]; +} + +export interface AppearanceStateDescriptor { + id: string; + selector: + | { kind: 'self'; suffix: string } + | { kind: 'ancestorPart'; part: string; suffix: string }; +} + +export interface AppearanceSurfaceDescriptor { + id: string; + parts: readonly AppearancePartDescriptor[]; + facets?: readonly AppearanceFacetDescriptor[]; + states?: readonly AppearanceStateDescriptor[]; +} + +export interface AppearanceRendererContext { + revision: number; + appearanceId: string; + mode: AppearanceMode; + globals: Readonly>; + assets: Readonly>; +} + +export interface AppearanceRendererAdapter { + id: K; + validate(settings: unknown): string[]; + apply( + next: Readonly | undefined, + previous: Readonly | undefined, + context: AppearanceRendererContext, + ): void | Promise; +} + +export type RegisteredAppearanceRendererAdapter = { + [K in AppearanceRendererId]: AppearanceRendererAdapter; +}[AppearanceRendererId]; + +export type ResolvedAppearanceStyle = Partial>; + +export interface ResolvedAppearanceSurface { + parts: Readonly>; +} + +export interface ResolvedAppearanceAsset { + definition: AppearancePackageAsset; + url?: string; + width?: number; + height?: number; + durationSeconds?: number; +} + +export interface ResolvedAppearanceBackgroundMedia extends AppearanceBackgroundMedia { + url?: string; + posterUrl?: string; +} + +export interface AppearanceDiagnostic { + level: 'warning' | 'error'; + code: string; + path: string; + message: string; +} + +export interface ResolvedAppearance { + revision: number; + id: string; + name: string; + version: string; + mode: AppearanceMode; + backgroundMedia?: Readonly; + globals: Readonly>; + materials: Readonly>; + components: Readonly>; + scenes: Readonly>; + renderers: Readonly<{ + [K in AppearanceRendererId]?: Readonly; + }>; + assets: Readonly>; + diagnostics: readonly AppearanceDiagnostic[]; + cssText: string; +} + +export interface AppearanceValidationIssue { + path: string; + code: string; + message: string; + context?: { + surfaceKind?: 'component' | 'scene'; + surfaceId?: string; + partId?: string; + allowedParts?: readonly string[]; + }; +} + +export interface AppearanceValidationResult { + valid: boolean; + errors: AppearanceValidationIssue[]; + warnings: AppearanceValidationIssue[]; + value?: AppearancePackage; +} + +export interface StoredAppearanceAsset { + mimeType: AppearancePackageAsset['mimeType']; + bytes: ArrayBuffer; + width?: number; + height?: number; + durationSeconds?: number; +} + +export interface StoredAppearancePackage { + manifest: AppearancePackage; + archive: ArrayBuffer; + assets: Record; + importedAt: string; +} + +export interface StoredAppearanceCatalogEntry { + id: string; + name: string; + author?: string; + description?: string; + version: string; + mode: AppearanceMode; + importedAt: string; +} + +export interface AppearanceCatalogEntry { + id: string; + name: string; + author?: string; + description?: string; + version: string; + mode: AppearanceMode; + source: 'builtin' | 'imported'; + importedAt?: string; +} + +export interface AppearanceStorage { + listCatalog(): Promise; + get(id: string): Promise; + put(value: StoredAppearancePackage): Promise; + delete(id: string): Promise; +} + +export interface AppearanceServiceSnapshot { + initialized: boolean; + status: 'initializing' | 'ready' | 'applying' | 'degraded'; + selectedAppearanceId: AppearanceSelectionId; + pendingSelectionId?: AppearanceSelectionId; + resolvedAppearanceId: string | null; + appearances: readonly AppearanceCatalogEntry[]; + current: ResolvedAppearance | null; + lastError?: string; +} diff --git a/src/web-ui/src/infrastructure/config/appearance.ts b/src/web-ui/src/infrastructure/config/appearance.ts new file mode 100644 index 0000000000..7dbb315b60 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/appearance.ts @@ -0,0 +1,48 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const configAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'config', + parts: [ + { id: 'root' }, + { id: 'content' }, + { id: 'contentInner' }, + { id: 'sectionStack' }, + { id: 'section' }, + { id: 'sectionHeader' }, + { id: 'sectionTitle' }, + { id: 'sectionDescription' }, + { id: 'sectionBody' }, + { id: 'row' }, + { id: 'rowLabel' }, + { id: 'rowDescription' }, + { id: 'rowControl' }, + { id: 'pageHeader' }, + { id: 'collectionSection' }, + { id: 'collectionItem' }, + { id: 'collectionRow' }, + { id: 'collectionMeta' }, + { id: 'collectionName' }, + { id: 'collectionControl' }, + { id: 'collectionDetails' }, + { id: 'collectionToolbar' }, + { id: 'collectionEditor' }, + { id: 'collectionFilters' }, + { id: 'collectionList' }, + { id: 'subagentModel' }, + { id: 'modelOption' }, + { id: 'modelSelection' }, + { id: 'modelSelectionOption' }, + { id: 'modelSelectionInput' }, + { id: 'modelSelectionLabel' }, + { id: 'modelSelectionDropdown' }, + { id: 'pageHeaderInner' }, + { id: 'pageHeaderInfo' }, + { id: 'pageHeaderTitle' }, + { id: 'pageHeaderSubtitle' }, + { id: 'pageHeaderExtra' }, + ], + facets: [ + { id: 'align', attribute: 'data-bf-align', values: ['start', 'center'] }, + { id: 'layout', attribute: 'data-bf-layout', values: ['default', 'multiline', 'wide', 'balanced'] }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/AIModelConfig.appearance.ts new file mode 100644 index 0000000000..a7f9d6b41f --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.appearance.ts @@ -0,0 +1,51 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const aiModelConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'ai-model-config', + parts: [ + { id: 'root' }, + { id: 'providerSelection' }, + { id: 'customOption' }, + { id: 'customOptionTitle' }, + { id: 'customOptionDescription' }, + { id: 'selectionDivider' }, + { id: 'providerGrid' }, + { id: 'providerCard' }, + { id: 'providerName' }, + { id: 'providerDescription' }, + { id: 'providerModels' }, + { id: 'providerTag' }, + { id: 'selectionActions' }, + { id: 'form' }, + { id: 'formBody' }, + { id: 'formActions' }, + { id: 'subscriptionArea' }, + { id: 'subscriptionNotice' }, + { id: 'subscriptionPanel' }, + { id: 'subscriptionSummary' }, + { id: 'subscriptionCode' }, + { id: 'subscriptionActions' }, + { id: 'collection' }, + { id: 'empty' }, + { id: 'providerGroup' }, + { id: 'providerGroupHeader' }, + { id: 'providerGroupTitle' }, + { id: 'providerGroupActions' }, + { id: 'providerGroupList' }, + { id: 'modelItem' }, + { id: 'modelMeta' }, + { id: 'modelActions' }, + { id: 'modelDetails' }, + { id: 'logoutConfirm' }, + { id: 'logoutActions' }, + ], + facets: [ + { id: 'view', attribute: 'data-bf-view', values: ['selection', 'settings'] }, + { id: 'status', attribute: 'data-bf-status', values: ['starting', 'pending', 'cancelling', 'failed'] }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts new file mode 100644 index 0000000000..31fe7d3e8b --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts @@ -0,0 +1,27 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const acpAgentsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'acp-agents-config', + parts: [ + { id: 'root' }, + { id: 'content' }, + { id: 'manager' }, + { id: 'toolbar' }, + { id: 'jsonEditor' }, + { id: 'jsonActions' }, + { id: 'empty' }, + { id: 'registryList' }, + { id: 'registryRow' }, + { id: 'registryMain' }, + { id: 'capabilities' }, + { id: 'status' }, + { id: 'confirmation' }, + { id: 'remoteList' }, + { id: 'remoteServer' }, + { id: 'remoteHeader' }, + { id: 'remoteAgents' }, + ], + facets: [ + { id: 'view', attribute: 'data-bf-view', values: ['registry', 'json'] }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts new file mode 100644 index 0000000000..3a836da0f3 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts @@ -0,0 +1,24 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const appearanceConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'appearance-config', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'settings' }, + { id: 'settingsContent' }, { id: 'language' }, { id: 'palettePicker' }, + { id: 'paletteSelect' }, { id: 'paletteOption' }, + { id: 'packageSection' }, { id: 'packageGrid' }, { id: 'packageCard' }, + { id: 'packageSelect' }, { id: 'packagePreview' }, { id: 'packageCardBody' }, + { id: 'packageActions' }, { id: 'packageActiveIndicator' }, { id: 'packageEmpty' }, + { id: 'packageDiagnostics' }, { id: 'packageDiagnosticsHeader' }, + { id: 'packageDiagnosticsGroup' }, { id: 'packageDiagnosticIssue' }, + { id: 'packageDiagnosticAllowedParts' }, + ], + facets: [ + { id: 'packageType', attribute: 'data-bf-package-type', values: ['native', 'imported'] }, + ], + states: [ + { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/BasicsConfig.appearance.ts new file mode 100644 index 0000000000..20e620dedd --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const basicsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'basics-config', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'launchAtLogin' }, { id: 'autoUpdate' }, + { id: 'logging' }, { id: 'logPath' }, { id: 'terminal' }, { id: 'shellOption' }, + { id: 'windowBehavior' }, { id: 'notifications' }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.appearance.ts new file mode 100644 index 0000000000..8a507436f6 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.appearance.ts @@ -0,0 +1,12 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const defaultModelConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'default-model-config', + parts: [ + { id: 'root' }, { id: 'loading' }, { id: 'empty' }, + { id: 'primaryModel' }, { id: 'lightweightModel' }, { id: 'embeddingModel' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/EditorConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/EditorConfig.appearance.ts new file mode 100644 index 0000000000..4aaed09a4c --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/EditorConfig.appearance.ts @@ -0,0 +1,12 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const editorConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'editor-config', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'actions' }, { id: 'saving' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'saving', selector: { kind: 'self', suffix: '[data-bf-state~="saving"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.appearance.ts b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.appearance.ts new file mode 100644 index 0000000000..e404e1a9f9 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const externalMcpOverviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'external-mcp-overview', + parts: [ + { id: 'root' }, { id: 'summary' }, { id: 'statusBadge' }, { id: 'serverDetails' }, + { id: 'detailItem' }, { id: 'detailLabel' }, { id: 'detailValue' }, + { id: 'import' }, { id: 'importPlan' }, { id: 'importList' }, { id: 'importOption' }, + { id: 'importOptionContent' }, { id: 'importOptionMeta' }, { id: 'importActions' }, { id: 'empty' }, + ], + states: [ + { id: 'pending', selector: { kind: 'self', suffix: '[data-bf-state~="pending"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'muted', selector: { kind: 'self', suffix: '[data-bf-state~="muted"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts new file mode 100644 index 0000000000..51472729d1 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts @@ -0,0 +1,37 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const externalSourcesConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'external-sources-config', + parts: [ + { id: 'root' }, + { id: 'hostMode' }, + { id: 'notice' }, + { id: 'policyCard' }, + { id: 'policySummary' }, + { id: 'policyRecovery' }, + { id: 'policyActions' }, + { id: 'diagnostics' }, + { id: 'sourceGroup' }, + { id: 'sourceDescription' }, + { id: 'sourceMembers' }, + { id: 'sourceControl' }, + { id: 'state' }, + { id: 'toolCard' }, + { id: 'toolTitle' }, + { id: 'toolDetail' }, + { id: 'toolWarning' }, + { id: 'toolActions' }, + { id: 'conflict' }, + { id: 'conflictTitle' }, + { id: 'conflictOptions' }, + { id: 'candidate' }, + { id: 'candidateState' }, + { id: 'candidateDetail' }, + { id: 'conflictHint' }, + { id: 'empty' }, + { id: 'ecosystemCard' }, + { id: 'ecosystemHeading' }, + { id: 'ecosystemName' }, + { id: 'ecosystemState' }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.appearance.ts b/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.appearance.ts new file mode 100644 index 0000000000..e1057cd0ab --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const globalPermissionRulesDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'global-permission-rules-dialog', + parts: [ + { id: 'root' }, { id: 'intro' }, { id: 'section' }, { id: 'sectionHeader' }, + { id: 'empty' }, { id: 'rules' }, { id: 'rule' }, { id: 'ruleActions' }, + { id: 'footer' }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/LspConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/LspConfig.appearance.ts new file mode 100644 index 0000000000..2a793b62c5 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/LspConfig.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const lspConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'lsp-config', + parts: [{ id: 'root' }, { id: 'message' }, { id: 'plugins' }], +}; diff --git a/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.appearance.ts b/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.appearance.ts new file mode 100644 index 0000000000..a1a12df599 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.appearance.ts @@ -0,0 +1,11 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const mcpResourceBrowserAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'mcp-resource-browser', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'headerActions' }, { id: 'search' }, + { id: 'content' }, { id: 'list' }, { id: 'loading' }, { id: 'empty' }, + { id: 'resource' }, { id: 'resourceIcon' }, { id: 'resourceInfo' }, + { id: 'viewer' }, { id: 'viewerHeader' }, { id: 'viewerContent' }, + ], + states: [{ id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }], +}; diff --git a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.appearance.ts new file mode 100644 index 0000000000..f396e0e06d --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.appearance.ts @@ -0,0 +1,26 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const mcpToolsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'mcp-tools-config', + parts: [ + { id: 'root' }, + { id: 'content' }, + { id: 'statusBadge' }, + { id: 'serverDetails' }, + { id: 'detailItem' }, + { id: 'detailLabel' }, + { id: 'detailValue' }, + { id: 'empty' }, + { id: 'jsonEditor' }, + { id: 'jsonHeader' }, + { id: 'jsonHint' }, + { id: 'jsonTextarea' }, + { id: 'jsonActions' }, + { id: 'examples' }, + { id: 'example' }, + { id: 'authEditor' }, + ], + facets: [ + { id: 'view', attribute: 'data-bf-view', values: ['list', 'json'] }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.appearance.ts new file mode 100644 index 0000000000..83c7ec6f3c --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const quickActionsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'quick-actions-config', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'list' }, { id: 'empty' }, + { id: 'dialog' }, { id: 'dialogIcon' }, { id: 'field' }, { id: 'dialogFooter' }, + { id: 'row' }, { id: 'rowIcon' }, { id: 'rowBody' }, { id: 'rowControls' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/SessionConfig.appearance.ts new file mode 100644 index 0000000000..802043184b --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.appearance.ts @@ -0,0 +1,37 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session-config', + parts: [ + { id: 'root' }, + { id: 'content' }, + { id: 'control' }, + { id: 'petPicker' }, + { id: 'petChooser' }, + { id: 'petTrigger' }, + { id: 'petList' }, + { id: 'petGroup' }, + { id: 'petOption' }, + { id: 'petOptionMain' }, + { id: 'petActions' }, + { id: 'platformNote' }, + { id: 'debugInputs' }, + { id: 'debugActions' }, + { id: 'templateModal' }, + { id: 'templateCard' }, + { id: 'templateHeader' }, + { id: 'templateInfo' }, + { id: 'templateContent' }, + { id: 'templateField' }, + { id: 'templateNotes' }, + { id: 'modalFooter' }, + { id: 'restartModal' }, + ], + facets: [ + { id: 'view', attribute: 'data-bf-view', values: ['personalization', 'permissions'] }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/SessionTitleConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/SessionTitleConfig.appearance.ts new file mode 100644 index 0000000000..cd79d70367 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SessionTitleConfig.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const sessionTitleConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'session-title-config', + parts: [{ id: 'root' }, { id: 'enableControl' }, { id: 'modelControl' }], +}; diff --git a/src/web-ui/src/infrastructure/config/components/SkillsConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/SkillsConfig.appearance.ts new file mode 100644 index 0000000000..11ae358043 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SkillsConfig.appearance.ts @@ -0,0 +1,17 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const skillsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'skills-config', + parts: [ + { id: 'root' }, { id: 'content' }, { id: 'form' }, { id: 'formBody' }, + { id: 'item' }, { id: 'details' }, { id: 'marketToolbar' }, + { id: 'marketList' }, { id: 'marketItem' }, { id: 'marketState' }, + { id: 'empty' }, { id: 'loading' }, { id: 'error' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'installed', selector: { kind: 'self', suffix: '[data-bf-state~="installed"]' } }, + { id: 'covered', selector: { kind: 'self', suffix: '[data-bf-state~="covered"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.appearance.ts new file mode 100644 index 0000000000..f92507e168 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const voiceInputConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'voice-input-config', + parts: [{ id: 'root' }, { id: 'modelList' }, { id: 'modelCard' }, { id: 'modelActions' }], +}; diff --git a/src/web-ui/src/infrastructure/config/components/VoiceInputDiagnostics.appearance.ts b/src/web-ui/src/infrastructure/config/components/VoiceInputDiagnostics.appearance.ts new file mode 100644 index 0000000000..9b37e3e6a3 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/VoiceInputDiagnostics.appearance.ts @@ -0,0 +1,23 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const voiceInputDiagnosticsAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'voice-input-diagnostics', + parts: [ + { id: 'root' }, { id: 'deviceControl' }, { id: 'deviceSelect' }, + { id: 'diagnosticAction' }, { id: 'level' }, { id: 'levelValue' }, + { id: 'recognitionTest' }, { id: 'note' }, { id: 'result' }, { id: 'error' }, + ], + facets: [ + { + id: 'phase', + attribute: 'data-bf-phase', + values: ['idle', 'preparing-microphone', 'checking-microphone', 'preparing-recognition', 'recording', 'transcribing'], + }, + { id: 'volume', attribute: 'data-bf-volume', values: ['silent', 'low', 'normal'] }, + ], + states: [ + { id: 'testingMicrophone', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="testing-microphone"]' } }, + { id: 'testingRecognition', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="testing-recognition"]' } }, + { id: 'error', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.appearance.ts new file mode 100644 index 0000000000..3092e99e80 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const worktreesConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'worktrees-config', + parts: [{ id: 'root' }, { id: 'results' }, { id: 'project' }, { id: 'worktreeList' }], +}; diff --git a/src/web-ui/src/infrastructure/font-preference/appearance.ts b/src/web-ui/src/infrastructure/font-preference/appearance.ts new file mode 100644 index 0000000000..f675a04789 --- /dev/null +++ b/src/web-ui/src/infrastructure/font-preference/appearance.ts @@ -0,0 +1,23 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const fontPreferenceAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'font-preference', + parts: [ + { id: 'root' }, + { id: 'levelGroup' }, + { id: 'levelButton' }, + { id: 'customControls' }, + { id: 'numberInput' }, + { id: 'error' }, + { id: 'preview' }, + { id: 'flowChatControls' }, + { id: 'resetButton' }, + ], + facets: [ + { id: 'level', attribute: 'data-bf-level', values: ['compact', 'small', 'default', 'medium', 'large', 'custom'] }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/i18n/appearance.ts b/src/web-ui/src/infrastructure/i18n/appearance.ts new file mode 100644 index 0000000000..d120ccd0d0 --- /dev/null +++ b/src/web-ui/src/infrastructure/i18n/appearance.ts @@ -0,0 +1,23 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const languageSelectorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'language-selector', + parts: [ + { id: 'root' }, + { id: 'trigger' }, + { id: 'icon' }, + { id: 'code' }, + { id: 'menu' }, + { id: 'option' }, + { id: 'optionLabel' }, + { id: 'check' }, + { id: 'loading' }, + ], + facets: [ + { id: 'mode', attribute: 'data-bf-mode', values: ['dropdown', 'inline', 'icon-only'] }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'changing', selector: { kind: 'self', suffix: '[data-bf-state~="changing"]' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/peer-device/appearance.ts b/src/web-ui/src/infrastructure/peer-device/appearance.ts new file mode 100644 index 0000000000..13b3cfc7ab --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/appearance.ts @@ -0,0 +1,35 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const peerDeviceAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'peer-device', + parts: [ + { id: 'overlay' }, + { id: 'dialog' }, + { id: 'header' }, + { id: 'title' }, + { id: 'closeButton' }, + { id: 'toolbar' }, + { id: 'toolButton' }, + { id: 'path' }, + { id: 'pathInput' }, + { id: 'pathDisplay' }, + { id: 'body' }, + { id: 'status' }, + { id: 'list' }, + { id: 'item' }, + { id: 'footer' }, + { id: 'selection' }, + { id: 'actions' }, + { id: 'badge' }, + { id: 'badgeIcon' }, + { id: 'badgeLabel' }, + { id: 'disconnectButton' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'disabled', selector: { kind: 'self', suffix: ':disabled' } }, + ], +}; diff --git a/src/web-ui/src/infrastructure/update/appearance.ts b/src/web-ui/src/infrastructure/update/appearance.ts new file mode 100644 index 0000000000..6bff54acaa --- /dev/null +++ b/src/web-ui/src/infrastructure/update/appearance.ts @@ -0,0 +1,33 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const updateAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'update', + parts: [ + { id: 'availableRoot' }, + { id: 'lead' }, + { id: 'leadIcon' }, + { id: 'subtitle' }, + { id: 'versions' }, + { id: 'versionRow' }, + { id: 'versionLabel' }, + { id: 'versionValue' }, + { id: 'notes' }, + { id: 'notesLabel' }, + { id: 'notesBody' }, + { id: 'actions' }, + { id: 'progressRoot' }, + { id: 'alert' }, + { id: 'progressBar' }, + { id: 'progressFill' }, + { id: 'progressHint' }, + { id: 'restartHint' }, + ], + facets: [ + { id: 'variant', attribute: 'data-bf-variant', values: ['daily', 'manual'] }, + { id: 'status', attribute: 'data-bf-status', values: ['downloading', 'error', 'installed'] }, + ], + states: [ + { id: 'highlight', selector: { kind: 'self', suffix: '[data-bf-state~="highlight"]' } }, + { id: 'indeterminate', selector: { kind: 'self', suffix: '[data-bf-state~="indeterminate"]' } }, + ], +}; diff --git a/src/web-ui/src/shared/announcement-system/appearance.ts b/src/web-ui/src/shared/announcement-system/appearance.ts new file mode 100644 index 0000000000..3035b79073 --- /dev/null +++ b/src/web-ui/src/shared/announcement-system/appearance.ts @@ -0,0 +1,19 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const announcementAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'announcement', + parts: [ + { id: 'stack' }, + { id: 'deck' }, + { id: 'ghost' }, + { id: 'modalBackdrop' }, + { id: 'modal' }, + { id: 'modalClose' }, + { id: 'modalPages' }, + { id: 'modalFooter' }, + { id: 'modalNavigation' }, + ], + facets: [ + { id: 'depth', attribute: 'data-bf-depth', values: ['1', '2'] }, + ], +}; diff --git a/src/web-ui/src/shared/context-menu-system/appearance.ts b/src/web-ui/src/shared/context-menu-system/appearance.ts new file mode 100644 index 0000000000..88d0827310 --- /dev/null +++ b/src/web-ui/src/shared/context-menu-system/appearance.ts @@ -0,0 +1,19 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const contextMenuAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'context-menu', + parts: [ + { id: 'root' }, + { id: 'item' }, + { id: 'separator' }, + { id: 'icon' }, + { id: 'label' }, + { id: 'shortcut' }, + { id: 'submenuArrow' }, + { id: 'submenu' }, + ], + states: [ + { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'submenuActive', selector: { kind: 'self', suffix: '[data-bf-state~="submenu-active"]' } }, + ], +}; diff --git a/src/web-ui/src/shared/context-system/appearance.ts b/src/web-ui/src/shared/context-system/appearance.ts new file mode 100644 index 0000000000..207328dc7a --- /dev/null +++ b/src/web-ui/src/shared/context-system/appearance.ts @@ -0,0 +1,28 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const contextListAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'context-list', + parts: [ + { id: 'root' }, + { id: 'empty' }, + { id: 'header' }, + { id: 'title' }, + { id: 'count' }, + { id: 'clear' }, + { id: 'items' }, + { id: 'item' }, + { id: 'card' }, + { id: 'cardIndicator' }, + { id: 'cardBody' }, + { id: 'cardToolbar' }, + { id: 'cardError' }, + { id: 'dropZone' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + { id: 'valid', selector: { kind: 'self', suffix: '[data-bf-state~="valid"]' } }, + { id: 'invalid', selector: { kind: 'self', suffix: '[data-bf-state~="invalid"]' } }, + { id: 'dragOver', selector: { kind: 'self', suffix: '[data-bf-state~="drag-over"]' } }, + { id: 'canAccept', selector: { kind: 'self', suffix: '[data-bf-state~="can-accept"]' } }, + ], +}; diff --git a/src/web-ui/src/shared/notification-system/appearance.ts b/src/web-ui/src/shared/notification-system/appearance.ts new file mode 100644 index 0000000000..52a850561c --- /dev/null +++ b/src/web-ui/src/shared/notification-system/appearance.ts @@ -0,0 +1,29 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const notificationAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'notification', + parts: [ + { id: 'container' }, + { id: 'item' }, + { id: 'itemIcon' }, + { id: 'itemContent' }, + { id: 'itemTitle' }, + { id: 'itemMessage' }, + { id: 'itemActions' }, + { id: 'itemClose' }, + { id: 'progressItem' }, + { id: 'progressBar' }, + { id: 'progressFill' }, + { id: 'loadingItem' }, + { id: 'centerRoot' }, + { id: 'centerHeader' }, + { id: 'centerList' }, + { id: 'centerItem' }, + ], + states: [ + { id: 'success', selector: { kind: 'self', suffix: '[data-bf-state~="success"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'unread', selector: { kind: 'self', suffix: '[data-bf-state~="unread"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/bitfun-canvas/appearance.ts b/src/web-ui/src/tools/bitfun-canvas/appearance.ts new file mode 100644 index 0000000000..e4f8e9f1e3 --- /dev/null +++ b/src/web-ui/src/tools/bitfun-canvas/appearance.ts @@ -0,0 +1,19 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const canvasToolAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'canvas-tool', + parts: [ + { id: 'root' }, + { id: 'empty' }, + { id: 'message' }, + { id: 'diagnostics' }, + { id: 'source' }, + { id: 'toolbar' }, + { id: 'frame' }, + { id: 'sourceOverlay' }, + { id: 'sourceDialog' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/editor/appearance.ts b/src/web-ui/src/tools/editor/appearance.ts new file mode 100644 index 0000000000..88031868b4 --- /dev/null +++ b/src/web-ui/src/tools/editor/appearance.ts @@ -0,0 +1,21 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const editorToolAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'editor-tool', + parts: [ + { id: 'root' }, + { id: 'content' }, + { id: 'loading' }, + { id: 'error' }, + { id: 'saving' }, + { id: 'readOnlyCodeBlock' }, + { id: 'meditorPreview' }, + { id: 'meditorEditArea' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'largeFile', selector: { kind: 'self', suffix: '[data-bf-state~="large-file"]' } }, + { id: 'fullscreen', selector: { kind: 'self', suffix: '[data-bf-state~="fullscreen"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/editor/components/DiffEditor.appearance.ts b/src/web-ui/src/tools/editor/components/DiffEditor.appearance.ts new file mode 100644 index 0000000000..5646e503c0 --- /dev/null +++ b/src/web-ui/src/tools/editor/components/DiffEditor.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const diffEditorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'diff-editor', + parts: [ + { id: 'root' }, { id: 'toolbar' }, { id: 'toolbarInfo' }, + { id: 'toolbarActions' }, { id: 'content' }, { id: 'loading' }, { id: 'error' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/editor/components/EditorBreadcrumb.appearance.ts b/src/web-ui/src/tools/editor/components/EditorBreadcrumb.appearance.ts new file mode 100644 index 0000000000..c16d6d31a2 --- /dev/null +++ b/src/web-ui/src/tools/editor/components/EditorBreadcrumb.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const editorBreadcrumbAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'editor-breadcrumb', + parts: [ + { id: 'root' }, { id: 'separator' }, { id: 'item' }, { id: 'itemIcon' }, + { id: 'itemText' }, { id: 'menu' }, { id: 'menuHeader' }, { id: 'menuBack' }, + { id: 'menuTitle' }, { id: 'loading' }, { id: 'empty' }, { id: 'list' }, + { id: 'listItem' }, + ], + states: [ + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/editor/components/EditorStatusBar.appearance.ts b/src/web-ui/src/tools/editor/components/EditorStatusBar.appearance.ts new file mode 100644 index 0000000000..5235153f1b --- /dev/null +++ b/src/web-ui/src/tools/editor/components/EditorStatusBar.appearance.ts @@ -0,0 +1,8 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const editorStatusBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'editor-status-bar', + parts: [ + { id: 'root' }, { id: 'left' }, { id: 'right' }, { id: 'item' }, + { id: 'selection' }, { id: 'separator' }, { id: 'lsp' }, + ], +}; diff --git a/src/web-ui/src/tools/editor/components/ImageViewer.appearance.ts b/src/web-ui/src/tools/editor/components/ImageViewer.appearance.ts new file mode 100644 index 0000000000..04e4117956 --- /dev/null +++ b/src/web-ui/src/tools/editor/components/ImageViewer.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const imageViewerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'image-viewer', + parts: [ + { id: 'root' }, { id: 'toolbar' }, { id: 'info' }, { id: 'controls' }, + { id: 'action' }, { id: 'container' }, { id: 'loading' }, { id: 'error' }, + { id: 'imageWrapper' }, { id: 'image' }, + ], + states: [{ id: 'fullscreen', selector: { kind: 'self', suffix: '[data-bf-state~="fullscreen"]' } }], +}; diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts b/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts new file mode 100644 index 0000000000..3a76b81f7c --- /dev/null +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const markdownEditorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'markdown-editor', + parts: [ + { id: 'root' }, { id: 'loading' }, { id: 'error' }, { id: 'notice' }, + { id: 'toolbar' }, { id: 'modeToggle' }, { id: 'actions' }, { id: 'body' }, + ], + facets: [{ id: 'view', attribute: 'data-bf-view', values: ['preview', 'markdown', 'source'] }], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/editor/components/PlanViewer.appearance.ts b/src/web-ui/src/tools/editor/components/PlanViewer.appearance.ts new file mode 100644 index 0000000000..05c68c8e91 --- /dev/null +++ b/src/web-ui/src/tools/editor/components/PlanViewer.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const planViewerAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'plan-viewer', + parts: [ + { id: 'root' }, { id: 'loading' }, { id: 'error' }, { id: 'header' }, + { id: 'headerMain' }, { id: 'headerActions' }, { id: 'content' }, { id: 'markdown' }, + { id: 'editorPanel' }, { id: 'editor' }, { id: 'todos' }, { id: 'todo' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/editor/components/StatusBarPopovers/StatusBarPopovers.appearance.ts b/src/web-ui/src/tools/editor/components/StatusBarPopovers/StatusBarPopovers.appearance.ts new file mode 100644 index 0000000000..ae0859e1be --- /dev/null +++ b/src/web-ui/src/tools/editor/components/StatusBarPopovers/StatusBarPopovers.appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const statusBarPopoversAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'status-bar-popover', + parts: [ + { id: 'root' }, { id: 'hint' }, { id: 'inputWrap' }, { id: 'input' }, + { id: 'list' }, { id: 'item' }, { id: 'itemIcon' }, + ], + facets: [ + { id: 'kind', attribute: 'data-bf-popover', values: ['line', 'indent', 'encoding', 'language'] }, + ], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/editor/meditor/components/MEditor.appearance.ts b/src/web-ui/src/tools/editor/meditor/components/MEditor.appearance.ts new file mode 100644 index 0000000000..a3bbeb57d0 --- /dev/null +++ b/src/web-ui/src/tools/editor/meditor/components/MEditor.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const mEditorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'm-editor', + parts: [ + { id: 'root' }, { id: 'toolbar' }, { id: 'content' }, + { id: 'editPanel' }, { id: 'previewPanel' }, { id: 'irPanel' }, + ], +}; diff --git a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.appearance.ts b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.appearance.ts new file mode 100644 index 0000000000..6e99a25a22 --- /dev/null +++ b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.appearance.ts @@ -0,0 +1,16 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const tiptapEditorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'tiptap-editor', + parts: [ + { id: 'root' }, + { id: 'content' }, + { id: 'inlineAi' }, + { id: 'inlineAiSurface' }, + { id: 'inlineAiPanel' }, + { id: 'composer' }, + { id: 'quickActions' }, + { id: 'quickAction' }, + { id: 'footer' }, + ], +}; diff --git a/src/web-ui/src/tools/file-system/appearance.ts b/src/web-ui/src/tools/file-system/appearance.ts new file mode 100644 index 0000000000..81a2c20186 --- /dev/null +++ b/src/web-ui/src/tools/file-system/appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const fileSystemAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'file-system', + parts: [ + { id: 'searchResults' }, + { id: 'empty' }, + { id: 'header' }, + { id: 'list' }, + { id: 'match' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/generative-widget/appearance.ts b/src/web-ui/src/tools/generative-widget/appearance.ts new file mode 100644 index 0000000000..2e3133e863 --- /dev/null +++ b/src/web-ui/src/tools/generative-widget/appearance.ts @@ -0,0 +1,22 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const generativeWidgetAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'generative-widget', + parts: [ + { id: 'root' }, + { id: 'empty' }, + { id: 'toolbar' }, + { id: 'toolbarMeta' }, + { id: 'toolbarActions' }, + { id: 'workspace' }, + { id: 'editorPane' }, + { id: 'paneHeader' }, + { id: 'editor' }, + { id: 'error' }, + { id: 'frame' }, + { id: 'iframe' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/git/appearance.ts b/src/web-ui/src/tools/git/appearance.ts new file mode 100644 index 0000000000..603d68a6ed --- /dev/null +++ b/src/web-ui/src/tools/git/appearance.ts @@ -0,0 +1,20 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const gitToolAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'git-tool', + parts: [ + { id: 'graphRoot' }, + { id: 'graphHeader' }, + { id: 'graphContent' }, + { id: 'graphCanvas' }, + { id: 'graphStatus' }, + { id: 'pushButton' }, + { id: 'diffEditor' }, + { id: 'createBranchDialog' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.appearance.ts b/src/web-ui/src/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.appearance.ts new file mode 100644 index 0000000000..30fac5a2d9 --- /dev/null +++ b/src/web-ui/src/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.appearance.ts @@ -0,0 +1,17 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const gitBranchHistoryAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'git-branch-history', + parts: [ + { id: 'root' }, { id: 'loading' }, { id: 'error' }, { id: 'header' }, + { id: 'headerLeft' }, { id: 'headerRight' }, { id: 'search' }, { id: 'content' }, + { id: 'empty' }, { id: 'commits' }, { id: 'commit' }, { id: 'commitMain' }, + { id: 'timeline' }, { id: 'commitInfo' }, { id: 'commitDetails' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/git/components/GitDiffView/GitDiffView.appearance.ts b/src/web-ui/src/tools/git/components/GitDiffView/GitDiffView.appearance.ts new file mode 100644 index 0000000000..e7bf7f1571 --- /dev/null +++ b/src/web-ui/src/tools/git/components/GitDiffView/GitDiffView.appearance.ts @@ -0,0 +1,18 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const gitDiffViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'git-diff-view', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'headerLeft' }, { id: 'headerRight' }, + { id: 'typeSwitcher' }, { id: 'typeOption' }, { id: 'content' }, { id: 'loading' }, { id: 'error' }, + { id: 'fileList' }, { id: 'file' }, { id: 'fileHeader' }, { id: 'fileInfo' }, + { id: 'fileStats' }, { id: 'diffContent' }, { id: 'diffLine' }, + { id: 'lineNumber' }, { id: 'lineContent' }, { id: 'empty' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/git/components/GitSettingsView/GitSettingsView.appearance.ts b/src/web-ui/src/tools/git/components/GitSettingsView/GitSettingsView.appearance.ts new file mode 100644 index 0000000000..099b0b10b1 --- /dev/null +++ b/src/web-ui/src/tools/git/components/GitSettingsView/GitSettingsView.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const gitSettingsViewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'git-settings-view', + parts: [ + { id: 'root' }, { id: 'loading' }, { id: 'error' }, { id: 'header' }, + { id: 'headerLeft' }, { id: 'headerRight' }, { id: 'status' }, { id: 'tabs' }, + { id: 'content' }, { id: 'section' }, { id: 'formGroup' }, { id: 'configItem' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/lsp/appearance.ts b/src/web-ui/src/tools/lsp/appearance.ts new file mode 100644 index 0000000000..36cfa3f5cc --- /dev/null +++ b/src/web-ui/src/tools/lsp/appearance.ts @@ -0,0 +1,17 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const lspAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'lsp', + parts: [ + { id: 'pluginList' }, + { id: 'empty' }, + { id: 'items' }, + { id: 'plugin' }, + { id: 'pluginHeader' }, + { id: 'pluginDetails' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/lsp/components/ReferencesPanel/ReferencesPanel.appearance.ts b/src/web-ui/src/tools/lsp/components/ReferencesPanel/ReferencesPanel.appearance.ts new file mode 100644 index 0000000000..8972f4373f --- /dev/null +++ b/src/web-ui/src/tools/lsp/components/ReferencesPanel/ReferencesPanel.appearance.ts @@ -0,0 +1,14 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const referencesPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'references-panel', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'title' }, { id: 'close' }, + { id: 'empty' }, { id: 'content' }, { id: 'fileGroup' }, { id: 'fileHeader' }, + { id: 'filePath' }, { id: 'fileCount' }, { id: 'list' }, { id: 'item' }, + { id: 'line' }, { id: 'preview' }, + ], + states: [ + { id: 'empty', selector: { kind: 'self', suffix: '[data-bf-state~="empty"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/terminal/appearance.ts b/src/web-ui/src/tools/terminal/appearance.ts new file mode 100644 index 0000000000..6d4cc96a1d --- /dev/null +++ b/src/web-ui/src/tools/terminal/appearance.ts @@ -0,0 +1,21 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const terminalToolAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'terminal-tool', + parts: [ + { id: 'root', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'terminal-tool' }, + { id: 'loading', visualRole: 'content' }, + { id: 'error', visualRole: 'content' }, + { id: 'toolbar', visualRole: 'toolbar', continuityGroup: 'terminal-tool' }, + { id: 'statusBar', visualRole: 'toolbar', continuityGroup: 'terminal-tool' }, + { id: 'terminal', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'terminal-tool' }, + { id: 'output', propertyProfile: 'layout', visualRole: 'continuous-surface', continuityGroup: 'terminal-tool' }, + { id: 'screen', propertyProfile: 'layout', visualRole: 'content' }, + ], + states: [ + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, + { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, + { id: 'running', selector: { kind: 'self', suffix: '[data-bf-state~="running"]' } }, + { id: 'exited', selector: { kind: 'self', suffix: '[data-bf-state~="exited"]' } }, + ], +}; diff --git a/src/web-ui/src/tools/workspace/appearance.ts b/src/web-ui/src/tools/workspace/appearance.ts new file mode 100644 index 0000000000..d5e5fed7d8 --- /dev/null +++ b/src/web-ui/src/tools/workspace/appearance.ts @@ -0,0 +1,15 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const workspaceToolAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'workspace-tool', + parts: [ + { id: 'root' }, + { id: 'currentSection' }, + { id: 'recentSection' }, + { id: 'currentCard' }, + { id: 'recentCard' }, + { id: 'assistantCard' }, + { id: 'empty' }, + { id: 'error' }, + ], +}; From d1cca78be79f3ae54a69515e0edad2350c4c869d Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 1 Aug 2026 14:21:19 +0800 Subject: [PATCH 2/9] feat(appearance): persist selection and bootstrap desktop startup --- package.json | 4 +- ...generate-startup-appearance-bootstrap.mjs} | 30 +-- ...ate-startup-appearance-bootstrap.test.mjs} | 4 +- .../desktop/src/{theme.rs => appearance.rs} | 248 ++++++++++++------ ...json => startup_appearance_bootstrap.json} | 8 +- src/apps/desktop/src/lib.rs | 12 +- src/apps/desktop/src/tray.rs | 4 +- .../assembly/core/src/external_sources.rs | 4 +- .../core/src/service/config/global.rs | 18 +- .../core/src/service/config/manager.rs | 119 +-------- .../core/src/service/config/providers.rs | 61 ++++- .../core/src/service/config/service.rs | 64 +---- .../assembly/core/src/service/config/types.rs | 95 ++----- .../startupPerformanceContract.test.ts | 59 ++--- src/web-ui/src/main.tsx | 15 +- src/web-ui/src/shared/constants/app.ts | 9 - src/web-ui/src/shared/types/global-state.ts | 2 - 17 files changed, 313 insertions(+), 443 deletions(-) rename scripts/{generate-startup-theme-bootstrap.mjs => generate-startup-appearance-bootstrap.mjs} (64%) rename scripts/{generate-startup-theme-bootstrap.test.mjs => generate-startup-appearance-bootstrap.test.mjs} (77%) rename src/apps/desktop/src/{theme.rs => appearance.rs} (79%) rename src/apps/desktop/src/generated/{startup_theme_bootstrap.json => startup_appearance_bootstrap.json} (93%) diff --git a/package.json b/package.json index f86047abca..dccfbe7f90 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "copy-icons": "copyfiles -f \"src/apps/desktop/icons/Logo-ICON.png\" \"src/web-ui/public/\"", "copy-assets": "pnpm run copy-monaco && pnpm run copy-icons", "generate-version": "node scripts/generate-version.cjs", - "generate-startup-theme-bootstrap": "node scripts/generate-startup-theme-bootstrap.mjs", - "generate-all": "pnpm run generate-version && pnpm run generate-startup-theme-bootstrap", + "generate-startup-appearance-bootstrap": "node scripts/generate-startup-appearance-bootstrap.mjs", + "generate-all": "pnpm run generate-version && pnpm run generate-startup-appearance-bootstrap", "postinstall": "pnpm run copy-assets", "dev": "node scripts/dev.cjs web", "dev:web": "pnpm --dir src/web-ui dev", diff --git a/scripts/generate-startup-theme-bootstrap.mjs b/scripts/generate-startup-appearance-bootstrap.mjs similarity index 64% rename from scripts/generate-startup-theme-bootstrap.mjs rename to scripts/generate-startup-appearance-bootstrap.mjs index 22ea04a59a..a9f89fb2f5 100644 --- a/scripts/generate-startup-theme-bootstrap.mjs +++ b/scripts/generate-startup-appearance-bootstrap.mjs @@ -10,11 +10,11 @@ const repoRoot = path.resolve(__dirname, '..'); const webUiRoot = path.join(repoRoot, 'src/web-ui'); const outputPath = path.join( repoRoot, - 'src/apps/desktop/src/generated/startup_theme_bootstrap.json', + 'src/apps/desktop/src/generated/startup_appearance_bootstrap.json', ); -const themePromptSnapshotOutputPath = path.join( +const appearancePromptSnapshotOutputPath = path.join( repoRoot, - 'src/crates/assembly/core/src/agentic/tools/implementations/generated/theme_prompt_snapshots.json', + 'src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json', ); const checkOnly = process.argv.includes('--check'); @@ -35,25 +35,25 @@ const server = await createServer({ try { const [ - { builtinThemes }, - { createStartupThemeBootstrapManifest }, - { createThemePromptSnapshotManifest }, + { builtinAppearancePalettes }, + { createStartupAppearanceBootstrapManifest }, + { createAppearancePromptSnapshotManifest }, ] = await Promise.all([ - server.ssrLoadModule('/src/infrastructure/theme/presets/index.ts'), - server.ssrLoadModule('/src/infrastructure/theme/presets/startupThemeBootstrap.ts'), - server.ssrLoadModule('/src/infrastructure/theme/presets/themePromptSnapshots.ts'), + server.ssrLoadModule('/src/infrastructure/appearance/builtins/palettes.ts'), + server.ssrLoadModule('/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts'), + server.ssrLoadModule('/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts'), ]); const generatedFiles = [ { - label: 'Startup theme bootstrap manifest', + label: 'Startup appearance bootstrap manifest', outputPath, - content: `${JSON.stringify(createStartupThemeBootstrapManifest(builtinThemes), null, 2)}\n`, + content: `${JSON.stringify(createStartupAppearanceBootstrapManifest(builtinAppearancePalettes), null, 2)}\n`, }, { - label: 'Theme prompt snapshot manifest', - outputPath: themePromptSnapshotOutputPath, - content: `${JSON.stringify(createThemePromptSnapshotManifest(builtinThemes), null, 2)}\n`, + label: 'Appearance prompt snapshot manifest', + outputPath: appearancePromptSnapshotOutputPath, + content: `${JSON.stringify(createAppearancePromptSnapshotManifest(builtinAppearancePalettes), null, 2)}\n`, }, ]; @@ -68,7 +68,7 @@ try { : normalizeGeneratedText(currentContent); if (currentContentForCheck !== normalizeGeneratedText(generatedFile.content)) { console.error( - `${generatedFile.label} is stale. Run \`pnpm run generate-startup-theme-bootstrap\`.`, + `${generatedFile.label} is stale. Run \`pnpm run generate-startup-appearance-bootstrap\`.`, ); process.exitCode = 1; } diff --git a/scripts/generate-startup-theme-bootstrap.test.mjs b/scripts/generate-startup-appearance-bootstrap.test.mjs similarity index 77% rename from scripts/generate-startup-theme-bootstrap.test.mjs rename to scripts/generate-startup-appearance-bootstrap.test.mjs index 6bdfd61ba1..e084060b1b 100644 --- a/scripts/generate-startup-theme-bootstrap.test.mjs +++ b/scripts/generate-startup-appearance-bootstrap.test.mjs @@ -6,8 +6,8 @@ function readText(filePath) { return fs.readFileSync(filePath, 'utf8'); } -test('startup theme bootstrap check is stable across line endings', () => { - const generatorSource = readText('scripts/generate-startup-theme-bootstrap.mjs'); +test('startup appearance bootstrap check is stable across line endings', () => { + const generatorSource = readText('scripts/generate-startup-appearance-bootstrap.mjs'); assert.match(generatorSource, /normalizeGeneratedText/, 'generator check should normalize line endings'); assert.match( diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/appearance.rs similarity index 79% rename from src/apps/desktop/src/theme.rs rename to src/apps/desktop/src/appearance.rs index c1a3eed1f9..4127932c38 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/appearance.rs @@ -1,4 +1,4 @@ -//! Theme System +//! Desktop appearance bootstrap and window creation. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{OnceLock, RwLock}; @@ -22,9 +22,11 @@ const AGENT_COMPANION_WINDOW_EDGE_MARGIN: f64 = 8.0; static AGENT_COMPANION_WINDOW_OPS: OnceLock> = OnceLock::new(); static AGENT_COMPANION_WINDOW_LAST_POSITION: OnceLock>>> = OnceLock::new(); -static STARTUP_THEME_BOOTSTRAP_MANIFEST: OnceLock = OnceLock::new(); +static STARTUP_APPEARANCE_BOOTSTRAP_MANIFEST: OnceLock = + OnceLock::new(); -const STARTUP_THEME_BOOTSTRAP_JSON: &str = include_str!("generated/startup_theme_bootstrap.json"); +const STARTUP_APPEARANCE_BOOTSTRAP_JSON: &str = + include_str!("generated/startup_appearance_bootstrap.json"); struct MainWebviewNavigationPolicy { first_page_navigation: AtomicBool, @@ -126,7 +128,7 @@ fn clamp_agent_companion_window_position( } #[derive(Debug, Clone)] -pub struct ThemeConfig { +pub struct AppearanceConfig { pub id: String, pub selection_id: Option, pub bg_primary: String, @@ -140,16 +142,16 @@ pub struct ThemeConfig { #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] -struct StartupThemeBootstrapManifest { - version: u8, - default_light_theme_id: String, - default_dark_theme_id: String, - themes: Vec, +struct StartupAppearanceBootstrapManifest { + schema_version: u8, + default_light_appearance_id: String, + default_dark_appearance_id: String, + appearances: Vec, } #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] -struct StartupThemeBootstrapTheme { +struct StartupAppearanceBootstrapEntry { id: String, bg_primary: String, bg_secondary: String, @@ -160,9 +162,9 @@ struct StartupThemeBootstrapTheme { accent_color: String, } -impl StartupThemeBootstrapTheme { - fn to_theme_config(&self, selection_id: Option) -> ThemeConfig { - ThemeConfig { +impl StartupAppearanceBootstrapEntry { + fn to_appearance_config(&self, selection_id: Option) -> AppearanceConfig { + AppearanceConfig { id: self.id.clone(), selection_id, bg_primary: self.bg_primary.clone(), @@ -178,7 +180,7 @@ impl StartupThemeBootstrapTheme { #[derive(Debug, Clone)] struct StartupBootstrapConfig { - theme: ThemeConfig, + appearance: AppearanceConfig, locale: String, keybindings: Option, } @@ -186,51 +188,55 @@ struct StartupBootstrapConfig { const MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES: usize = 64 * 1024; const MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES: usize = 64 * 1024; -impl Default for ThemeConfig { +impl Default for AppearanceConfig { fn default() -> Self { - let default_light_theme_id = Self::startup_theme_bootstrap_manifest() - .default_light_theme_id + let default_light_appearance_id = Self::startup_appearance_bootstrap_manifest() + .default_light_appearance_id .as_str(); - let mut theme = Self::get_builtin_theme(default_light_theme_id) - .expect("startup theme bootstrap manifest must include the default light theme"); - theme.selection_id = None; - theme + let mut appearance = Self::get_builtin_appearance(default_light_appearance_id).expect( + "startup appearance bootstrap manifest must include the default light appearance", + ); + appearance.selection_id = None; + appearance } } -impl ThemeConfig { - pub fn get_builtin_theme(theme_id: &str) -> Option { - Self::startup_theme_bootstrap_manifest() - .themes +impl AppearanceConfig { + pub fn get_builtin_appearance(appearance_id: &str) -> Option { + Self::startup_appearance_bootstrap_manifest() + .appearances .iter() - .find(|theme| theme.id == theme_id) - .map(|theme| theme.to_theme_config(Some(theme_id.to_string()))) + .find(|appearance| appearance.id == appearance_id) + .map(|appearance| appearance.to_appearance_config(Some(appearance_id.to_string()))) } - fn startup_theme_bootstrap_manifest() -> &'static StartupThemeBootstrapManifest { - STARTUP_THEME_BOOTSTRAP_MANIFEST.get_or_init(|| { - let manifest: StartupThemeBootstrapManifest = - serde_json::from_str(STARTUP_THEME_BOOTSTRAP_JSON) - .expect("startup theme bootstrap manifest must be valid JSON"); + fn startup_appearance_bootstrap_manifest() -> &'static StartupAppearanceBootstrapManifest { + STARTUP_APPEARANCE_BOOTSTRAP_MANIFEST.get_or_init(|| { + let manifest: StartupAppearanceBootstrapManifest = + serde_json::from_str(STARTUP_APPEARANCE_BOOTSTRAP_JSON) + .expect("startup appearance bootstrap manifest must be valid JSON"); assert_eq!( - manifest.version, 1, - "startup theme bootstrap manifest version is unsupported" + manifest.schema_version, 1, + "startup appearance bootstrap manifest version is unsupported" ); manifest }) } fn load_startup_bootstrap_config() -> StartupBootstrapConfig { - let default_theme = Self::default(); + let default_appearance = Self::default(); let default = StartupBootstrapConfig { - theme: default_theme.clone(), + appearance: default_appearance.clone(), locale: "zh-CN".to_string(), keybindings: None, }; let path_manager = match try_get_path_manager_arc() { Ok(pm) => pm, Err(e) => { - debug!("Failed to create PathManager, using default theme: {}", e); + debug!( + "Failed to create PathManager, using default appearance: {}", + e + ); return default; } }; @@ -243,7 +249,10 @@ impl ThemeConfig { let config_content = match std::fs::read_to_string(&config_file) { Ok(content) => content, Err(e) => { - debug!("Failed to read config file, using default theme: {}", e); + debug!( + "Failed to read config file, using default appearance: {}", + e + ); return default; } }; @@ -251,7 +260,10 @@ impl ThemeConfig { let config_value: serde_json::Value = match serde_json::from_str(&config_content) { Ok(value) => value, Err(e) => { - debug!("Failed to parse config file, using default theme: {}", e); + debug!( + "Failed to parse config file, using default appearance: {}", + e + ); return default; } }; @@ -267,51 +279,56 @@ impl ThemeConfig { .unwrap_or("zh-CN") .to_string(); + let appearance_selection = config_value + .pointer("/appearance/selection") + .and_then(|value| value.as_str()) + .unwrap_or("system") + .to_string(); + let global_config: GlobalConfig = match serde_json::from_value(config_value) { Ok(config) => config, Err(e) => { - debug!("Failed to parse config file, using default theme: {}", e); + debug!( + "Failed to parse config file, using default appearance: {}", + e + ); return StartupBootstrapConfig { locale, ..default }; } }; - let theme_id = global_config - .themes - .as_ref() - .map(|t| t.current.as_str()) - .unwrap_or("bitfun-light"); - - let resolved_id = Self::resolve_builtin_theme_id(theme_id); + let resolved_id = Self::resolve_builtin_appearance_id(&appearance_selection); - let theme = match Self::get_builtin_theme(resolved_id) { + let appearance = match Self::get_builtin_appearance(resolved_id) { Some(mut config) => { - config.selection_id = Some(theme_id.to_string()); + config.selection_id = Some(appearance_selection.clone()); config } None => { - warn!("Unknown theme ID: {}, using default theme", theme_id); - default_theme + warn!( + "Unknown appearance ID: {}, using default appearance", + appearance_selection + ); + default_appearance } }; StartupBootstrapConfig { - theme, + appearance, locale, keybindings: global_config.app.keybindings, } } - /// Maps config `themes.current` to a built-in id for splash / window chrome. - /// `system` follows OS light/dark (aligned with web-ui `getSystemPreferredDefaultThemeId`). - fn resolve_builtin_theme_id(theme_id: &str) -> &str { - if theme_id == "system" { - let manifest = Self::startup_theme_bootstrap_manifest(); + /// Resolves the selected appearance for splash and window chrome. + fn resolve_builtin_appearance_id(appearance_id: &str) -> &str { + if appearance_id == "system" { + let manifest = Self::startup_appearance_bootstrap_manifest(); return match dark_light::detect() { - Mode::Dark => manifest.default_dark_theme_id.as_str(), - Mode::Light | Mode::Default => manifest.default_light_theme_id.as_str(), + Mode::Dark => manifest.default_dark_appearance_id.as_str(), + Mode::Light | Mode::Default => manifest.default_light_appearance_id.as_str(), }; } - theme_id + appearance_id } fn startup_messages_json(locale: &str) -> String { @@ -347,7 +364,7 @@ impl ThemeConfig { bootstrap_config: &StartupBootstrapConfig, workspace_startup_state: Option<&serde_json::Value>, ) -> String { - let theme_type = if self.is_light { "light" } else { "dark" }; + let appearance_mode = if self.is_light { "light" } else { "dark" }; let startup_locale = &bootstrap_config.locale; let startup_locale_json = serde_json::to_string(&startup_locale).unwrap_or_else(|_| "\"zh-CN\"".to_string()); @@ -362,9 +379,9 @@ impl ThemeConfig { let perf_trace_enabled = cfg!(debug_assertions) || ((cfg!(feature = "devtools") || std::env::var_os("BITFUN_PERF_TRACE").is_some()) && std::env::var_os("BITFUN_WEBDRIVER_PORT").is_some()); - let bootstrap_theme_id_json = + let bootstrap_appearance_id_json = serde_json::to_string(&self.id).unwrap_or_else(|_| "\"bitfun-light\"".to_string()); - let bootstrap_theme_selection_json = self + let bootstrap_appearance_selection_json = self .selection_id .as_ref() .and_then(|selection| serde_json::to_string(selection).ok()) @@ -389,24 +406,24 @@ impl ThemeConfig { window.__BITFUN_BOOTSTRAP_LOCALE__ = {startup_locale_json}; window.__BITFUN_BOOTSTRAP_MESSAGES__ = {startup_messages_json}; window.__BITFUN_SHOW_STARTUP_WINDOW_CONTROLS__ = {show_startup_window_controls}; - window.__BITFUN_BOOTSTRAP_THEME_ID__ = {bootstrap_theme_id_json}; - window.__BITFUN_BOOTSTRAP_THEME_SELECTION__ = {bootstrap_theme_selection_json}; + window.__BITFUN_BOOTSTRAP_APPEARANCE_ID__ = {bootstrap_appearance_id_json}; + window.__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__ = {bootstrap_appearance_selection_json}; {bootstrap_keybindings_assignment} {bootstrap_workspace_startup_state_assignment} - function applyTheme() {{ + function applyAppearance() {{ var root = document.documentElement; if (!root) return false; - root.setAttribute('data-theme', '{id}'); - root.setAttribute('data-theme-type', '{theme_type}'); + root.setAttribute('data-bf-appearance', '{id}'); + root.setAttribute('data-bf-appearance-mode', '{appearance_mode}'); - root.style.setProperty('--color-bg-primary', '{bg_primary}'); - root.style.setProperty('--color-bg-secondary', '{bg_secondary}'); - root.style.setProperty('--color-bg-tertiary', '{bg_primary}'); - root.style.setProperty('--color-bg-workbench', '{bg_primary}'); - root.style.setProperty('--color-bg-flowchat', '{bg_scene}'); - root.style.setProperty('--color-bg-scene', '{bg_scene}'); - root.style.setProperty('--color-text-primary', '{text_primary}'); + root.style.setProperty('--bf-appearance-token-color-bg-primary', '{bg_primary}'); + root.style.setProperty('--bf-appearance-token-color-bg-secondary', '{bg_secondary}'); + root.style.setProperty('--bf-appearance-token-color-bg-tertiary', '{bg_primary}'); + root.style.setProperty('--bf-appearance-token-color-bg-workbench', '{bg_primary}'); + root.style.setProperty('--bf-appearance-token-color-bg-scene', '{bg_scene}'); + root.style.setProperty('--bf-appearance-token-color-text-primary', '{text_primary}'); + root.style.setProperty('--bf-appearance-token-color-accent-500', '{accent_color}'); root.style.backgroundColor = '{bg_primary}'; if (document.body) {{ @@ -417,22 +434,25 @@ impl ThemeConfig { }} if (document.documentElement) {{ - applyTheme(); + applyAppearance(); }} if (document.readyState === 'loading') {{ - document.addEventListener('DOMContentLoaded', applyTheme); + document.addEventListener('DOMContentLoaded', applyAppearance); }} else {{ - applyTheme(); + applyAppearance(); }} }})(); "#, id = self.id, - theme_type = theme_type, + appearance_mode = appearance_mode, + bootstrap_appearance_id_json = bootstrap_appearance_id_json, + bootstrap_appearance_selection_json = bootstrap_appearance_selection_json, bg_primary = self.bg_primary, bg_secondary = self.bg_secondary, bg_scene = self.bg_scene, text_primary = self.text_primary, + accent_color = self.accent_color, startup_trace_id_json = startup_trace_id_json, perf_trace_enabled = perf_trace_enabled, bootstrap_log_level_json = bootstrap_log_level_json, @@ -454,6 +474,64 @@ impl ThemeConfig { } } +#[cfg(test)] +mod tests { + use super::{AppearanceConfig, StartupBootstrapConfig}; + + #[test] + fn startup_init_script_uses_only_appearance_bootstrap_contracts() { + let appearance = AppearanceConfig { + id: "test.appearance".to_string(), + selection_id: Some("test.appearance".to_string()), + bg_primary: "#101214".to_string(), + bg_secondary: "#181a1d".to_string(), + bg_scene: "#202328".to_string(), + is_light: false, + text_primary: "#f5f7fa".to_string(), + text_muted: "#9aa1aa".to_string(), + accent_color: "#60a5fa".to_string(), + }; + let bootstrap = StartupBootstrapConfig { + appearance: appearance.clone(), + locale: "en-US".to_string(), + keybindings: None, + }; + + let script = appearance.generate_init_script("trace-id", &bootstrap, None); + + assert!(script.contains("__BITFUN_BOOTSTRAP_APPEARANCE_ID__")); + assert!(script.contains("__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__")); + assert!(script.contains("data-bf-appearance")); + assert!(script.contains("data-bf-appearance-mode")); + assert!(script.contains("--bf-appearance-token-color-bg-primary")); + assert!(script.contains("--bf-appearance-token-color-bg-scene")); + assert!(script.contains("--bf-appearance-token-color-text-primary")); + assert!(script.contains("--bf-appearance-token-color-accent-500")); + let retired_bootstrap_global = ["__BITFUN_BOOTSTRAP", "THEME"].join("_"); + let retired_background_token = ["--", "color-bg-"].concat(); + let retired_text_token = ["--", "color-text-"].concat(); + assert!(!script.contains(&retired_bootstrap_global)); + assert!(!script.contains("data-theme")); + assert!(!script.contains(&retired_background_token)); + assert!(!script.contains(&retired_text_token)); + } + + #[test] + fn startup_manifest_exposes_both_default_appearances() { + let manifest = AppearanceConfig::startup_appearance_bootstrap_manifest(); + + assert_eq!(manifest.schema_version, 1); + assert!( + AppearanceConfig::get_builtin_appearance(&manifest.default_light_appearance_id) + .is_some() + ); + assert!( + AppearanceConfig::get_builtin_appearance(&manifest.default_dark_appearance_id) + .is_some() + ); + } +} + pub fn create_main_window( app_handle: &tauri::AppHandle, startup_trace_id: &str, @@ -461,10 +539,10 @@ pub fn create_main_window( workspace_startup_state: Option, ) { let total_started_at = Instant::now(); - let bootstrap_config = ThemeConfig::load_startup_bootstrap_config(); - let theme = bootstrap_config.theme.clone(); - let bg_color = theme.to_tauri_color(); - let init_script = theme.generate_init_script( + let bootstrap_config = AppearanceConfig::load_startup_bootstrap_config(); + let appearance = bootstrap_config.appearance.clone(); + let bg_color = appearance.to_tauri_color(); + let init_script = appearance.generate_init_script( startup_trace_id, &bootstrap_config, workspace_startup_state.as_ref(), @@ -472,11 +550,11 @@ pub fn create_main_window( startup_trace.record_step( "native_step_end", "native_window", - "prepare_theme", + "prepare_appearance", total_started_at.elapsed().as_millis(), ); debug!( - "Main window creation step completed: step=prepare_theme duration_ms={}", + "Main window creation step completed: step=prepare_appearance duration_ms={}", total_started_at.elapsed().as_millis() ); diff --git a/src/apps/desktop/src/generated/startup_theme_bootstrap.json b/src/apps/desktop/src/generated/startup_appearance_bootstrap.json similarity index 93% rename from src/apps/desktop/src/generated/startup_theme_bootstrap.json rename to src/apps/desktop/src/generated/startup_appearance_bootstrap.json index 011db96b98..f219eeb9c4 100644 --- a/src/apps/desktop/src/generated/startup_theme_bootstrap.json +++ b/src/apps/desktop/src/generated/startup_appearance_bootstrap.json @@ -1,8 +1,8 @@ { - "version": 1, - "defaultLightThemeId": "bitfun-light", - "defaultDarkThemeId": "bitfun-dark", - "themes": [ + "schemaVersion": 1, + "defaultLightAppearanceId": "bitfun-light", + "defaultDarkAppearanceId": "bitfun-dark", + "appearances": [ { "id": "bitfun-light", "bgPrimary": "#f3f3f5", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 0ec02fbfa1..7adfa5a95f 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -21,6 +21,7 @@ //! attribute is touched. pub mod api; +pub mod appearance; pub mod computer_use; pub mod crash_diagnostics; mod embedded_relay_host; @@ -29,7 +30,6 @@ pub mod macos_menubar; pub mod runtime; pub mod sleep_prevention; pub mod startup_trace; -pub mod theme; pub mod tray; use bitfun_core::agentic::tools::computer_use_capability::set_computer_use_desktop_available; @@ -907,7 +907,7 @@ pub async fn run() { }; let window_started = Instant::now(); startup_trace.record_phase("main_window_create_start", "native_window"); - theme::create_main_window( + appearance::create_main_window( &app_handle, &startup_trace_id, &startup_trace, @@ -1117,7 +1117,7 @@ pub async fn run() { } }) .invoke_handler(tauri::generate_handler![ - theme::show_main_window, + appearance::show_main_window, hide_main_window_after_close_request, api::agentic_api::create_session, api::agentic_api::update_session_model, @@ -1222,9 +1222,9 @@ pub async fn run() { get_app_state, update_app_status, update_workspace_info, - theme::show_agent_companion_desktop_pet, - theme::hide_agent_companion_desktop_pet, - theme::resize_agent_companion_desktop_pet, + appearance::show_agent_companion_desktop_pet, + appearance::hide_agent_companion_desktop_pet, + appearance::resize_agent_companion_desktop_pet, list_agent_companion_pets, import_agent_companion_pet_package, delete_agent_companion_pet_package, diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 831223b1f0..f70e9e020b 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -159,9 +159,9 @@ async fn tray_toggle_desktop_pet(app: &AppHandle) -> Result<(), String> { let show = desktop_pet_should_show(&exp); if show { - crate::theme::show_agent_companion_desktop_pet(app.clone()).await?; + crate::appearance::show_agent_companion_desktop_pet(app.clone()).await?; } else { - crate::theme::hide_agent_companion_desktop_pet(app.clone()).await?; + crate::appearance::hide_agent_companion_desktop_pet(app.clone()).await?; } Ok(()) diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 9717c021de..7f3a69ea47 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -7492,8 +7492,8 @@ mod tests { &ConfigUpdateEvent::ModelConfigurationUpdated )); assert!(!config_update_refreshes_external_model_bindings( - &ConfigUpdateEvent::ThemeUpdated { - theme_id: "dark".to_string(), + &ConfigUpdateEvent::AppearanceUpdated { + appearance_id: "bitfun-dark".to_string(), } )); } diff --git a/src/crates/assembly/core/src/service/config/global.rs b/src/crates/assembly/core/src/service/config/global.rs index 9e955eba30..9f92a8352c 100644 --- a/src/crates/assembly/core/src/service/config/global.rs +++ b/src/crates/assembly/core/src/service/config/global.rs @@ -35,8 +35,8 @@ pub enum ConfigUpdateEvent { model_id: String, model_name: String, }, - /// Theme configuration updated. - ThemeUpdated { theme_id: String }, + /// Web UI appearance selection updated. + AppearanceUpdated { appearance_id: String }, /// Editor configuration updated. EditorUpdated, /// Terminal configuration updated. @@ -212,14 +212,16 @@ impl GlobalConfigManager { Ok(()) } - /// Updates the theme configuration and broadcasts an event. - pub async fn update_theme(&self, theme_id: &str) -> BitFunResult<()> { + /// Updates the Web UI appearance selection and broadcasts an event. + pub async fn update_appearance(&self, appearance_id: &str) -> BitFunResult<()> { let service = Self::get_service().await?; - service.set_config("theme.id", theme_id).await?; - let stored_theme_id: String = service.get_config(Some("themes.current")).await?; + service + .set_config("appearance.selection", appearance_id) + .await?; + let stored_appearance_id: String = service.get_config(Some("appearance.selection")).await?; - Self::broadcast_update(ConfigUpdateEvent::ThemeUpdated { - theme_id: stored_theme_id, + Self::broadcast_update(ConfigUpdateEvent::AppearanceUpdated { + appearance_id: stored_appearance_id, }) .await; diff --git a/src/crates/assembly/core/src/service/config/manager.rs b/src/crates/assembly/core/src/service/config/manager.rs index 79ade13f24..de715df55d 100644 --- a/src/crates/assembly/core/src/service/config/manager.rs +++ b/src/crates/assembly/core/src/service/config/manager.rs @@ -20,68 +20,10 @@ type ConfigMigration = (&'static str, &'static str, ConfigMigrationFn); fn canonical_config_path(path: &str) -> &str { match path { "ai.review_teams.rate_limit_status" => "ai.review_team_rate_limit_status", - "theme.id" => "themes.current", _ => path, } } -fn normalize_legacy_theme_id(theme_id: &str) -> String { - match theme_id.trim() { - "dark" => "bitfun-dark".to_string(), - "light" => "bitfun-light".to_string(), - normalized => normalized.to_string(), - } -} - -fn normalize_legacy_theme_value(value: Value) -> Value { - match value { - Value::String(theme_id) => Value::String(normalize_legacy_theme_id(&theme_id)), - value => value, - } -} - -pub(crate) fn normalize_legacy_theme_config_value(mut config: Value) -> Value { - let legacy_theme_id = config - .get("theme") - .and_then(|theme| theme.get("id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|theme_id| !theme_id.is_empty()) - .map(normalize_legacy_theme_id); - - let Some(config_object) = config.as_object_mut() else { - return config; - }; - - config_object.remove("theme"); - - let Some(legacy_theme_id) = legacy_theme_id else { - return config; - }; - - let themes_value = config_object - .entry("themes".to_string()) - .or_insert_with(|| serde_json::json!({})); - - if !themes_value.is_object() { - *themes_value = serde_json::json!({}); - } - - if let Some(themes_object) = themes_value.as_object_mut() { - let has_current = themes_object - .get("current") - .and_then(Value::as_str) - .map(str::trim) - .is_some_and(|current| !current.is_empty()); - - if !has_current { - themes_object.insert("current".to_string(), Value::String(legacy_theme_id)); - } - } - - config -} - /// Moves the only trustworthy legacy mode choice into the new default domain. /// /// Historical global model switching rewrote every `ai.agent_models` entry, @@ -161,9 +103,7 @@ pub(crate) fn normalize_legacy_tool_permissions_config_value(mut config: Value) fn normalize_legacy_config_value(config: Value) -> Value { normalize_legacy_tool_permissions_config_value( - normalize_legacy_agent_model_defaults_config_value(normalize_legacy_theme_config_value( - config, - )), + normalize_legacy_agent_model_defaults_config_value(config), ) } @@ -491,14 +431,10 @@ impl ConfigManager { T: serde::Serialize, { let old_config = self.config.clone(); - let mut json_value = serde_json::to_value(value) + let json_value = serde_json::to_value(value) .map_err(|e| BitFunError::config(format!("Failed to serialize config value: {}", e)))?; - let original_path = path; let path = canonical_config_path(path); - if original_path == "theme.id" { - json_value = normalize_legacy_theme_value(json_value); - } self.set_value_by_path(path, json_value)?; self.config.last_modified = chrono::Utc::now(); @@ -895,7 +831,7 @@ pub(crate) fn migrate_0_0_0_to_1_0_0(mut config: Value) -> BitFunResult { mod tests { use super::{ canonical_config_path, config_value_for_persistence, - normalize_legacy_agent_model_defaults_config_value, normalize_legacy_theme_config_value, + normalize_legacy_agent_model_defaults_config_value, normalize_legacy_tool_permissions_config_value, }; use crate::service::config::types::GlobalConfig; @@ -910,55 +846,6 @@ mod tests { canonical_config_path("ai.review_teams.default"), "ai.review_teams.default" ); - assert_eq!(canonical_config_path("theme.id"), "themes.current"); - } - - #[test] - fn legacy_theme_id_moves_to_themes_current_when_missing() { - let normalized = normalize_legacy_theme_config_value(serde_json::json!({ - "theme": { - "id": "dark", - "colors": { - "background": "#1e1e1e" - } - } - })); - - assert_eq!(normalized["themes"]["current"], "bitfun-dark"); - assert!( - normalized.get("theme").is_none(), - "legacy GUI theme payload should not survive normalization" - ); - } - - #[test] - fn legacy_theme_id_does_not_override_existing_theme_selection() { - let normalized = normalize_legacy_theme_config_value(serde_json::json!({ - "theme": { - "id": "bitfun-dark" - }, - "themes": { - "current": "bitfun-cyber" - } - })); - - assert_eq!(normalized["themes"]["current"], "bitfun-cyber"); - assert!(normalized.get("theme").is_none()); - } - - #[test] - fn legacy_theme_id_fills_empty_existing_theme_selection() { - let normalized = normalize_legacy_theme_config_value(serde_json::json!({ - "theme": { - "id": "light" - }, - "themes": { - "current": "" - } - })); - - assert_eq!(normalized["themes"]["current"], "bitfun-light"); - assert!(normalized.get("theme").is_none()); } #[test] diff --git a/src/crates/assembly/core/src/service/config/providers.rs b/src/crates/assembly/core/src/service/config/providers.rs index b763cb89af..9d2229992a 100644 --- a/src/crates/assembly/core/src/service/config/providers.rs +++ b/src/crates/assembly/core/src/service/config/providers.rs @@ -160,26 +160,57 @@ impl ConfigProvider for AIConfigProvider { } } -/// Theme system configuration provider (new, supports theme management). -pub struct ThemesConfigProvider; +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn rejects_a_model_context_window_smaller_than_32k() { + let mut config = AIConfig::default(); + config.models.push(AIModelConfig { + name: "Test model".to_string(), + provider: "openai".to_string(), + context_window: Some(MIN_MODEL_CONTEXT_WINDOW_TOKENS - 1), + ..AIModelConfig::default() + }); + let value = serde_json::to_value(config).expect("AI config should serialize"); + + let error = AIConfigProvider + .validate_config(&value) + .await + .expect_err("small context windows must be rejected"); + + assert!(error + .to_string() + .contains("context_window must be at least 32000")); + } +} + +/// Web UI appearance selection provider. +pub struct AppearanceConfigProvider; #[async_trait] -impl ConfigProvider for ThemesConfigProvider { +impl ConfigProvider for AppearanceConfigProvider { fn name(&self) -> &str { - "themes" + "appearance" } fn get_default_config(&self) -> serde_json::Value { - serialize_default_config("themes", ThemesConfig::default()) + serialize_default_config("appearance", AppearanceConfig::default()) } async fn validate_config(&self, config: &serde_json::Value) -> BitFunResult> { let warnings = Vec::new(); - if let Ok(_themes_config) = serde_json::from_value::(config.clone()) { + if let Ok(appearance_config) = serde_json::from_value::(config.clone()) { + if appearance_config.selection.trim().is_empty() { + return Err(BitFunError::validation( + "Appearance selection must not be empty".to_string(), + )); + } } else { return Err(BitFunError::validation( - "Invalid themes config format".to_string(), + "Invalid appearance config format".to_string(), )); } @@ -191,10 +222,12 @@ impl ConfigProvider for ThemesConfigProvider { _old_config: &serde_json::Value, new_config: &serde_json::Value, ) -> BitFunResult<()> { - if let Ok(themes_config) = serde_json::from_value::(new_config.clone()) { + if let Ok(appearance_config) = + serde_json::from_value::(new_config.clone()) + { info!( - "Themes config changed: current theme = {}", - themes_config.current + "Appearance config changed: selection = {}", + appearance_config.selection ); } Ok(()) @@ -253,8 +286,8 @@ impl ConfigProvider for EditorConfigProvider { ) -> BitFunResult<()> { if let Ok(editor_config) = serde_json::from_value::(new_config.clone()) { info!( - "Editor config changed: font_size={}, theme={}", - editor_config.font_size, editor_config.theme + "Editor config changed: font_size={}", + editor_config.font_size ); } Ok(()) @@ -471,7 +504,7 @@ impl ConfigProviderRegistry { }; registry.register(Box::new(AIConfigProvider)); - registry.register(Box::new(ThemesConfigProvider)); + registry.register(Box::new(AppearanceConfigProvider)); registry.register(Box::new(EditorConfigProvider)); registry.register(Box::new(TerminalConfigProvider)); registry.register(Box::new(WorkspaceConfigProvider)); @@ -565,7 +598,7 @@ impl ConfigProviderRegistry { ) -> BitFunResult { match section { "app" => Ok(serde_json::to_value(&config.app)?), - "themes" => Ok(serde_json::to_value(&config.themes)?), + "appearance" => Ok(serde_json::to_value(&config.appearance)?), "editor" => Ok(serde_json::to_value(&config.editor)?), "terminal" => Ok(serde_json::to_value(&config.terminal)?), "workspace" => Ok(serde_json::to_value(&config.workspace)?), diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 37218c4c8d..bd8c2227f7 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -842,70 +842,28 @@ mod tests { } #[tokio::test] - async fn legacy_theme_id_path_writes_themes_current_only() { - let (service, _dir) = test_service("legacy-theme-id-path").await; + async fn appearance_selection_round_trips_through_the_typed_config() { + let (service, _dir) = test_service("appearance-selection").await; service - .set_config("theme.id", "dark") + .set_config("appearance.selection", "bitfun-dark") .await - .expect("legacy theme path should remain a thin compatibility alias"); + .expect("appearance selection should save"); - let current: String = service - .get_config(Some("themes.current")) + let selection: String = service + .get_config(Some("appearance.selection")) .await - .expect("theme selection should be readable from the TS-owned path"); - assert_eq!(current, "bitfun-dark"); + .expect("appearance selection should load"); + assert_eq!(selection, "bitfun-dark"); let export: GlobalConfig = service .get_config(None) .await .expect("full config should load"); let serialized = serde_json::to_value(export).expect("config should serialize"); - assert!( - serialized.get("theme").is_none(), - "legacy path must not recreate the removed Rust GUI theme schema" - ); - } - - #[tokio::test] - async fn raw_import_preserves_legacy_theme_id_before_deserialization() { - let (service, _dir) = test_service("legacy-theme-raw-import").await; - let mut raw_config = - serde_json::to_value(GlobalConfig::default()).expect("default config should serialize"); - let raw_object = raw_config - .as_object_mut() - .expect("default config should serialize as an object"); - raw_object.remove("themes"); - raw_object.insert( - "theme".to_string(), - serde_json::json!({ - "id": "dark", - "colors": { - "background": "#1e1e1e" - } - }), - ); - - service - .import_config_data(raw_config) - .await - .expect("raw legacy config should import before old fields are dropped"); - - let current: String = service - .get_config(Some("themes.current")) - .await - .expect("legacy theme id should migrate into themes.current"); - assert_eq!(current, "bitfun-dark"); - - let export: GlobalConfig = service - .get_config(None) - .await - .expect("full config should load after import"); - let serialized = serde_json::to_value(export).expect("config should serialize"); - assert!( - serialized.get("theme").is_none(), - "legacy theme payload should not be exported after import" - ); + assert_eq!(serialized["appearance"]["selection"], "bitfun-dark"); + assert!(serialized.get("theme").is_none()); + assert!(serialized.get("themes").is_none()); } #[tokio::test] diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 8be7d83ff4..2d0f550358 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -71,9 +71,8 @@ pub struct GlobalConfig { /// ACP client configuration (stored as `{ "acpClients": { ... } }`). #[serde(skip_serializing_if = "Option::is_none")] pub acp_clients: Option, - /// Theme system configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub themes: Option, + /// Web UI appearance selection. The full package contract is owned by the frontend. + pub appearance: AppearanceConfig, /// Web UI font size preferences (`get_config` / `set_config` path `font`). #[serde(skip_serializing_if = "Option::is_none")] pub font: Option, @@ -424,23 +423,18 @@ pub struct NotificationConfig { pub enable_startup_tips: bool, } -/// Theme system configuration. The full GUI theme contract is owned by TS/web-ui; -/// Rust stores only the selected theme id and opaque custom-theme payloads. +/// Web UI appearance configuration. Rust stores only the selected package id. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] -pub struct ThemesConfig { - /// Currently active theme ID. - pub current: String, - /// User-defined themes (stored as JSON). - #[serde(skip_serializing_if = "Option::is_none")] - pub custom: Option, +pub struct AppearanceConfig { + /// Selected appearance package ID or `system`. + pub selection: String, } -impl Default for ThemesConfig { +impl Default for AppearanceConfig { fn default() -> Self { Self { - current: "bitfun-light".to_string(), - custom: None, + selection: "system".to_string(), } } } @@ -457,7 +451,6 @@ pub struct EditorConfig { pub word_wrap: String, pub line_numbers: String, pub minimap: MinimapConfig, - pub theme: String, pub auto_save: String, pub auto_save_delay: u32, pub format_on_save: bool, @@ -486,34 +479,6 @@ pub struct TerminalConfig { pub cursor_blink: bool, pub cursor_style: String, pub scrollback: u32, - /// Terminal ANSI palette/defaults. This keeps the historical - /// `terminal.theme` config key but is not the GUI theme contract. - pub theme: TerminalThemeConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct TerminalThemeConfig { - pub background: String, - pub foreground: String, - pub cursor: String, - pub selection: String, - pub black: String, - pub red: String, - pub green: String, - pub yellow: String, - pub blue: String, - pub magenta: String, - pub cyan: String, - pub white: String, - pub bright_black: String, - pub bright_red: String, - pub bright_green: String, - pub bright_yellow: String, - pub bright_blue: String, - pub bright_magenta: String, - pub bright_cyan: String, - pub bright_white: String, } /// Workspace configuration. @@ -604,7 +569,9 @@ pub struct DefaultModelsConfig { #[serde(tag = "kind", rename_all = "snake_case")] #[derive(Default)] pub enum SubagentModelSelection { - Fixed { model_id: String }, + Fixed { + model_id: String, + }, #[default] Inherit, } @@ -624,7 +591,6 @@ impl SubagentModelSelection { } } - /// Model defaults for subagents created through user-visible delegation. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -1673,7 +1639,7 @@ impl Default for GlobalConfig { tool_permissions: ToolPermissionConfig::default(), mcp_servers: None, acp_clients: None, - themes: Some(ThemesConfig::default()), + appearance: AppearanceConfig::default(), font: None, version: "1.0.0".to_string(), last_modified: chrono::Utc::now(), @@ -1772,7 +1738,6 @@ impl Default for EditorConfig { side: "right".to_string(), size: "proportional".to_string(), }, - theme: "vs".to_string(), auto_save: "afterDelay".to_string(), auto_save_delay: 1000, format_on_save: true, @@ -1792,34 +1757,6 @@ impl Default for TerminalConfig { cursor_blink: true, cursor_style: "block".to_string(), scrollback: 1000, - theme: TerminalThemeConfig::default(), - } - } -} - -impl Default for TerminalThemeConfig { - fn default() -> Self { - Self { - background: "#1e1e1e".to_string(), - foreground: "#d4d4d4".to_string(), - cursor: "#d4d4d4".to_string(), - selection: "#264f78".to_string(), - black: "#000000".to_string(), - red: "#cd3131".to_string(), - green: "#0dbc79".to_string(), - yellow: "#e5e510".to_string(), - blue: "#2472c8".to_string(), - magenta: "#bc3fbc".to_string(), - cyan: "#11a8cd".to_string(), - white: "#e5e5e5".to_string(), - bright_black: "#666666".to_string(), - bright_red: "#f14c4c".to_string(), - bright_green: "#23d18b".to_string(), - bright_yellow: "#f5f543".to_string(), - bright_blue: "#3b8eea".to_string(), - bright_magenta: "#d670d6".to_string(), - bright_cyan: "#29b8db".to_string(), - bright_white: "#e5e5e5".to_string(), } } } @@ -2325,7 +2262,7 @@ mod tests { } #[test] - fn global_config_serialization_omits_legacy_theme_section() { + fn global_config_serialization_uses_appearance_selection() { let serialized = serde_json::to_value(GlobalConfig::default()).expect("config should serialize"); @@ -2333,10 +2270,8 @@ mod tests { serialized.get("theme").is_none(), "Rust config must not export the removed GUI theme schema" ); - assert_eq!( - serialized["themes"]["current"], "bitfun-light", - "theme selection remains in the TS-owned themes contract" - ); + assert!(serialized.get("themes").is_none()); + assert_eq!(serialized["appearance"]["selection"], "system"); } #[test] diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 5fc1518e53..6bc9a75855 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -89,24 +89,24 @@ describe('startup performance contract', () => { it('does not block first React render on frontend log-level config reads', () => { const mainSource = readSource('../../main.tsx'); const loggerSource = readSource('../../shared/utils/logger.ts'); - const themeSource = readSource('../../../../apps/desktop/src/theme.rs'); + const appearanceSource = readSource('../../../../apps/desktop/src/appearance.rs'); expect(mainSource).not.toContain("before_render_step', 'initialize_frontend_log_level_sync'"); expect(mainSource).not.toContain('before_render_step", "initialize_frontend_log_level_sync"'); expect(mainSource).toContain('initializeFrontendLogLevelSync'); expect(mainSource).toContain('installFrontendLogLevelConfigWatcher'); expect(loggerSource).toContain('__BITFUN_BOOTSTRAP_LOG_LEVEL__'); - expect(themeSource).toContain('__BITFUN_BOOTSTRAP_LOG_LEVEL__'); + expect(appearanceSource).toContain('__BITFUN_BOOTSTRAP_LOG_LEVEL__'); }); it('keeps startup keybindings on the bootstrap path instead of a first-window IPC', () => { const configManagerSource = readSource('../../infrastructure/config/services/ConfigManager.ts'); - const themeSource = readSource('../../../../apps/desktop/src/theme.rs'); + const appearanceSource = readSource('../../../../apps/desktop/src/appearance.rs'); - expect(themeSource).toContain('__BITFUN_BOOTSTRAP_KEYBINDINGS__'); - expect(themeSource).toContain('keybindings: global_config.app.keybindings'); - expect(themeSource).toContain('MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES'); - expect(themeSource).toContain('.filter(|json| json.len() <= MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES)'); + expect(appearanceSource).toContain('__BITFUN_BOOTSTRAP_KEYBINDINGS__'); + expect(appearanceSource).toContain('keybindings: global_config.app.keybindings'); + expect(appearanceSource).toContain('MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES'); + expect(appearanceSource).toContain('.filter(|json| json.len() <= MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES)'); expect(configManagerSource).toContain('consumeBootstrapOptionalConfig'); expect(configManagerSource).toContain('__BITFUN_BOOTSTRAP_KEYBINDINGS__'); expect(configManagerSource).toContain("path !== 'app.keybindings'"); @@ -115,12 +115,12 @@ describe('startup performance contract', () => { it('keeps workspace startup state on the bootstrap path with command fallback', () => { const globalStateSource = readSource('../../shared/types/global-state.ts'); - const desktopThemeSource = readSource('../../../../apps/desktop/src/theme.rs'); + const desktopAppearanceSource = readSource('../../../../apps/desktop/src/appearance.rs'); const desktopLibSource = readSource('../../../../apps/desktop/src/lib.rs'); const desktopCommandsSource = readSource('../../../../apps/desktop/src/api/commands.rs'); - expect(desktopThemeSource).toContain('__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__'); - expect(desktopThemeSource).toContain('MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES'); + expect(desktopAppearanceSource).toContain('__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__'); + expect(desktopAppearanceSource).toContain('MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES'); expect(desktopLibSource).toContain('prepare_workspace_startup_bootstrap_snapshot'); expect(desktopLibSource).toContain('tokio::task::block_in_place'); expect(desktopLibSource).not.toContain('tauri::async_runtime::block_on(prepare_workspace_startup_bootstrap_snapshot'); @@ -148,28 +148,22 @@ describe('startup performance contract', () => { expect(resourceTimingSource).not.toContain('createLogger'); }); - it('keeps built-in theme startup on the bootstrap path without pre-render config writes', () => { + it('initializes the appearance runtime before the first React render', () => { const mainSource = readSource('../../main.tsx'); - const themeServiceSource = readSource('../../infrastructure/theme/core/ThemeService.ts'); - const desktopThemeSource = readSource('../../../../apps/desktop/src/theme.rs'); - - expect(desktopThemeSource).toContain('__BITFUN_BOOTSTRAP_THEME_ID__'); - expect(desktopThemeSource).toContain('__BITFUN_BOOTSTRAP_THEME_SELECTION__'); - expect(desktopThemeSource).toContain('include_str!("generated/startup_theme_bootstrap.json")'); - expect(desktopThemeSource).not.toContain('"bitfun-slate" => Some(Self'); - expect(mainSource).toContain("before_render_step', 'theme_service_initialize'"); - expect(themeServiceSource).toContain('getBootstrapThemeSelection'); - expect(themeServiceSource).toContain('applyThemeSelection(bootstrapSelection, { persist: false })'); - expect(themeServiceSource).toContain('applyThemeSelection(saved, { persist: false })'); - expect(themeServiceSource).toContain('ensureUserThemesLoaded'); - expect(mainSource).toContain('themeService.ensureUserThemesLoaded()'); - expect(mainSource.indexOf('themeService.ensureUserThemesLoaded()')).toBeGreaterThan( - mainSource.indexOf('async function initializeAfterRender()'), + const appearanceServiceSource = readSource( + '../../infrastructure/appearance/runtime/AppearanceService.ts', ); + + expect(mainSource).toContain("before_render_step', 'appearance_initialize'"); + expect(mainSource).toContain('await appearanceService.initialize()'); + expect(mainSource).not.toContain('themeService'); + expect(appearanceServiceSource).toContain('selectedAppearanceId'); + expect(appearanceServiceSource).toContain('getSystemAppearanceId()'); + expect(appearanceServiceSource).not.toContain('themes.current'); }); - it('restores only normal main-window geometry and repairs legacy floating sizes', () => { - const desktopThemeSource = readSource('../../../../apps/desktop/src/theme.rs'); + it('centers the first main window and persists geometry before close handling', () => { + const desktopAppearanceSource = readSource('../../../../apps/desktop/src/appearance.rs'); const desktopLibSource = readSource('../../../../apps/desktop/src/lib.rs'); const toolbarModeProviderSource = readSource( '../../flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx' @@ -178,9 +172,8 @@ describe('startup performance contract', () => { const invokeHandlerStart = desktopLibSource.indexOf('.invoke_handler(', windowEventStart); const windowEventSource = desktopLibSource.slice(windowEventStart, invokeHandlerStart); - expect(desktopThemeSource).toContain('crate::MAIN_WINDOW_DEFAULT_WIDTH'); - expect(desktopThemeSource).toContain('crate::restore_main_window_state(&window)'); - expect(desktopThemeSource).not.toContain('windows_maximize_show_wait_action'); + expect(desktopAppearanceSource).toContain('.inner_size(1200.0, 800.0)\n .center()'); + expect(desktopAppearanceSource).not.toContain('windows_maximize_show_wait_action'); expect(desktopLibSource).toContain('tauri_plugin_window_state::Builder::default()'); expect(desktopLibSource).toContain('.with_state_flags(StateFlags::empty())'); expect(desktopLibSource).toContain('.with_filter(|label| label == "main")'); @@ -473,8 +466,8 @@ describe('startup performance contract', () => { ); }); - it('keeps theme startup from importing the Monaco runtime', () => { - const source = readSource('../../infrastructure/theme/integrations/MonacoThemeSync.ts'); + it('keeps appearance startup from importing the Monaco runtime', () => { + const source = readSource('../../infrastructure/appearance/adapters/MonacoAppearanceAdapter.ts'); expect(source).not.toMatch(/import\s+\*\s+as\s+monaco\s+from\s+['"]monaco-editor['"]/); expect(source).toMatch(/import\s+type\s+\*\s+as\s+Monaco\s+from\s+['"]monaco-editor['"]/); diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index b8f819bb61..1bb6711ec4 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -209,7 +209,7 @@ document.addEventListener( true ); -/** Logger, theme, and minimal deps — must finish before first React paint (F5 / webview reload does not re-run Tauri init script). */ +/** Logger, appearance, and minimal deps must finish before the first React paint. */ async function initializeBeforeRender(): Promise { const phaseStartedAt = nowMs(); startupTrace.markPhase('before_render_start'); @@ -221,12 +221,12 @@ async function initializeBeforeRender(): Promise { log.info('Initializing BitFun'); - await traceStartupStep('before_render_step', 'theme_service_initialize', async () => { + await traceStartupStep('before_render_step', 'appearance_initialize', async () => { await measureAsyncAndLog(log, 'Startup step completed', async () => { - const { themeService } = await import('./infrastructure/theme'); - await themeService.initialize(); + const { appearanceService } = await import('./infrastructure/appearance'); + await appearanceService.initialize(); }, { - data: { step: 'themeService.initialize' }, + data: { step: 'appearanceService.initialize' }, }); }); log.info('Theme system initialized'); @@ -274,10 +274,6 @@ async function initializeAfterRender(): Promise { ); ensureSettingsAppliedListener(); })(), - (async () => { - const { themeService } = await import('./infrastructure/theme'); - await themeService.ensureUserThemesLoaded(); - })(), (async () => { const { registerDefaultContextTypes } = await import('./shared/context-system/core/registerDefaultTypes'); registerDefaultContextTypes(); @@ -308,7 +304,6 @@ async function initializeAfterRender(): Promise { 'EditorConfigPreload', 'LogLevelConfigWatcher', 'SettingsAppliedListener', - 'UserThemes', 'DefaultContextTypes', 'RecommendationProviders', 'Tools', diff --git a/src/web-ui/src/shared/constants/app.ts b/src/web-ui/src/shared/constants/app.ts index 6980593f75..194853bb53 100644 --- a/src/web-ui/src/shared/constants/app.ts +++ b/src/web-ui/src/shared/constants/app.ts @@ -11,7 +11,6 @@ export const APP_INFO = { export const STORAGE_KEYS = { - THEME: 'bitfun-theme', LANGUAGE: 'bitfun-language', LEFT_PANEL_WIDTH: 'bitfun-left-panel-width', LEFT_PANEL_COLLAPSED: 'bitfun-left-panel-collapsed', @@ -40,13 +39,6 @@ export const calculateDefaultPanelWidth = (windowWidth: number = window.innerWid }; -// export const THEMES = { -// LIGHT: 'light', -// DARK: 'dark', -// AUTO: 'auto' -// } as const; - - export const SUPPORTED_FILE_TYPES = { CODE: [ 'js', 'jsx', 'ts', 'tsx', 'vue', 'py', 'java', 'cpp', 'c', 'cs', 'php', @@ -101,7 +93,6 @@ export const MODEL_PROVIDERS = { export const DEFAULT_CONFIG = { - THEME: 'auto', LANGUAGE: 'zh-CN', AUTO_SAVE: true, AUTO_ANALYZE: false, diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index d289d417da..0cbd48c7bf 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -33,7 +33,6 @@ export enum AppStatus { export interface UserSettings { - theme: string; language: string; autoSaveInterval: number; maxCachedGraphs: number; @@ -285,7 +284,6 @@ function mapApiStatus(status: APIAppStatus): AppStatus { function createDefaultUserSettings(): UserSettings { return { - theme: 'system', language: 'en-US', autoSaveInterval: 0, maxCachedGraphs: 0, From 4f5732040c97f9677ad4fd44b4c44b0659f057f8 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 1 Aug 2026 14:21:59 +0800 Subject: [PATCH 3/9] feat(settings): manage appearance packages and selection --- .../src/app/scenes/settings/SettingsNav.scss | 79 +-- .../src/app/scenes/settings/SettingsNav.tsx | 26 +- .../app/scenes/settings/SettingsScene.scss | 12 +- .../src/app/scenes/settings/SettingsScene.tsx | 46 +- .../components/ArchivedSessionsConfig.scss | 54 +- .../components/ArchivedSessionsConfig.tsx | 20 +- .../components/KeyboardShortcutsTab.scss | 71 ++- .../components/KeyboardShortcutsTab.tsx | 79 ++- .../src/app/scenes/settings/settingsConfig.ts | 5 +- .../config/components/AIFeaturesConfig.scss | 90 +-- .../config/components/AIModelConfig.scss | 554 +++++++++--------- .../config/components/AIModelConfig.tsx | 92 +-- .../config/components/AcpAgentsConfig.scss | 108 ++-- .../config/components/AcpAgentsConfig.tsx | 166 +++++- .../config/components/AppearanceConfig.scss | 359 ++++++++++-- .../config/components/AppearanceConfig.tsx | 421 ++++--------- .../AppearancePackageConfigSection.test.tsx | 125 ++++ .../AppearancePackageConfigSection.tsx | 414 +++++++++++++ .../config/components/BasicsConfig.scss | 28 +- .../config/components/BasicsConfig.tsx | 22 +- .../config/components/ConfigForm.scss | 148 ++--- .../config/components/DebugConfig.scss | 42 +- .../config/components/DefaultModelConfig.scss | 90 +-- .../config/components/DefaultModelConfig.tsx | 12 +- .../config/components/EditorConfig.scss | 4 +- .../config/components/EditorConfig.tsx | 12 +- .../config/components/ExternalMcpOverview.tsx | 46 +- .../components/ExternalSourcesConfig.scss | 187 +++--- .../components/ExternalSourcesConfig.tsx | 234 ++++---- .../GlobalPermissionRulesDialog.scss | 38 +- .../GlobalPermissionRulesDialog.tsx | 18 +- .../config/components/LspConfig.tsx | 6 +- .../config/components/MCPResourceBrowser.scss | 57 +- .../config/components/MCPResourceBrowser.tsx | 29 +- .../McpToolsConfig.presentation.test.ts | 21 + .../config/components/McpToolsConfig.scss | 82 +-- .../config/components/McpToolsConfig.tsx | 114 ++-- .../components/ModelSelectPresentation.scss | 4 +- .../components/ModelSelectPresentation.tsx | 4 +- .../components/ModelSelectionRadio.scss | 28 +- .../config/components/ModelSelectionRadio.tsx | 22 +- .../config/components/QuickActionsConfig.scss | 46 +- .../config/components/QuickActionsConfig.tsx | 30 +- .../config/components/SessionConfig.tsx | 87 +-- .../config/components/SessionTitleConfig.tsx | 6 +- .../config/components/SkillsConfig.scss | 136 ++--- .../config/components/SkillsConfig.tsx | 49 +- .../components/SubagentModelConfig.scss | 4 +- .../config/components/SubagentModelConfig.tsx | 2 +- .../config/components/VoiceInputConfig.scss | 100 ++-- .../config/components/VoiceInputConfig.tsx | 38 +- .../components/VoiceInputDiagnostics.tsx | 31 +- .../config/components/WorktreesConfig.scss | 174 +++--- .../config/components/WorktreesConfig.tsx | 16 +- .../common/ConfigCollectionItem.scss | 64 +- .../common/ConfigCollectionItem.tsx | 14 +- .../common/ConfigCollectionSection.scss | 30 +- .../common/ConfigCollectionSection.tsx | 10 +- .../common/ConfigPageHeader.example.tsx | 10 +- .../components/common/ConfigPageHeader.scss | 71 ++- .../components/common/ConfigPageHeader.tsx | 15 +- .../components/common/ConfigPageLayout.scss | 81 ++- .../common/ConfigPageLayout.test.tsx | 37 +- .../components/common/ConfigPageLayout.tsx | 46 +- .../components/form-controls/ConfigForm.tsx | 8 +- .../form-controls/ConfigSection.tsx | 2 +- src/web-ui/src/infrastructure/config/index.ts | 2 +- .../infrastructure/config/schemas/editor.json | 37 -- .../src/infrastructure/config/types/index.ts | 3 - src/web-ui/src/locales/en-US/settings.json | 18 +- .../locales/en-US/settings/appearance.json | 41 +- .../src/locales/en-US/settings/basics.json | 26 +- src/web-ui/src/locales/zh-CN/settings.json | 18 +- .../locales/zh-CN/settings/appearance.json | 37 +- .../src/locales/zh-CN/settings/basics.json | 26 +- src/web-ui/src/locales/zh-TW/settings.json | 18 +- .../locales/zh-TW/settings/appearance.json | 37 +- .../src/locales/zh-TW/settings/basics.json | 26 +- 78 files changed, 3154 insertions(+), 2211 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/components/AppearancePackageConfigSection.test.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/AppearancePackageConfigSection.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/McpToolsConfig.presentation.test.ts diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.scss b/src/web-ui/src/app/scenes/settings/SettingsNav.scss index 698cd9b8cf..39563c5559 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.scss +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.scss @@ -24,9 +24,9 @@ } &__title { - font-size: var(--font-size-sm); + font-size: var(--bf-appearance-token-font-size-sm); font-weight: 600; - color: var(--color-text-primary); + color: var(--bf-appearance-token-color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -49,33 +49,33 @@ padding: 0 $size-gap-2; gap: $size-gap-2; border: none; - border-radius: $size-radius-base; - background: var(--color-bg-scene); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-subtle) 65%, transparent); + border-radius: $size-radius-full; + background: var(--bf-appearance-token-color-bg-scene); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bf-appearance-token-border-subtle) 65%, transparent); transition: background 0.2s ease, box-shadow 0.2s ease; } .search__wrapper:hover { - background: color-mix(in srgb, var(--color-bg-scene) 88%, var(--element-bg-medium)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-medium) 55%, transparent); + background: color-mix(in srgb, var(--bf-appearance-token-color-bg-scene) 88%, var(--bf-appearance-token-element-bg-medium)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bf-appearance-token-border-medium) 55%, transparent); } .search__wrapper:focus-within { - background: color-mix(in srgb, var(--color-bg-scene) 82%, var(--element-bg-medium)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-medium) 68%, transparent); + background: color-mix(in srgb, var(--bf-appearance-token-color-bg-scene) 82%, var(--bf-appearance-token-element-bg-medium)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bf-appearance-token-border-medium) 68%, transparent); } &.search .search__input { - font-size: var(--font-size-sm); + font-size: var(--bf-appearance-token-font-size-sm); &::placeholder { - color: var(--color-text-disabled); + color: var(--bf-appearance-token-color-text-disabled); } &:focus::placeholder { - color: color-mix(in srgb, var(--color-text-disabled) 72%, transparent); + color: color-mix(in srgb, var(--bf-appearance-token-color-text-disabled) 72%, transparent); } } } @@ -97,10 +97,10 @@ border: none; border-radius: $size-radius-base; background: transparent; - color: var(--color-text-secondary); + color: var(--bf-appearance-token-color-text-secondary); cursor: pointer; text-align: left; - font-size: var(--font-size-sm); + font-size: var(--bf-appearance-token-font-size-sm); font-weight: 400; width: 100%; position: relative; @@ -108,23 +108,23 @@ background $motion-fast $easing-standard; &:hover { - color: var(--color-text-primary); - background: var(--element-bg-soft); + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-soft); } &.is-highlighted { - color: var(--color-text-primary); - background: var(--element-bg-soft); + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-soft); } &.is-active { - color: var(--color-text-primary); - background: transparent; + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-soft); font-weight: 500; } &:focus-visible { - outline: 2px solid var(--color-accent-500); + outline: 2px solid var(--bf-appearance-token-color-accent-500); outline-offset: -1px; } } @@ -137,23 +137,23 @@ &__search-result-desc { width: 100%; - font-size: var(--font-size-2xs); + font-size: var(--bf-appearance-token-font-size-2xs); line-height: 1.35; - color: var(--color-text-muted); + color: var(--bf-appearance-token-color-text-muted); font-weight: 400; } &__search-empty { padding: $size-gap-3 $size-gap-3; - font-size: var(--font-size-sm); - color: var(--color-text-muted); + font-size: var(--bf-appearance-token-font-size-sm); + color: var(--bf-appearance-token-color-text-muted); text-align: center; } &__search-highlight { padding: 0; margin: 0; - background: color-mix(in srgb, var(--color-accent-500) 28%, transparent); + background: color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 28%, transparent); color: inherit; font-weight: inherit; border-radius: 2px; @@ -170,7 +170,7 @@ &::-webkit-scrollbar { width: 3px; } &::-webkit-scrollbar-track { background: transparent; } &::-webkit-scrollbar-thumb { - background: var(--border-subtle); + background: var(--bf-appearance-token-border-subtle); border-radius: 2px; } } @@ -190,10 +190,11 @@ } &__category-label { - font-size: var(--font-size-2xs); + font-size: var(--bf-appearance-token-font-size-2xs); font-weight: 700; - letter-spacing: 0.015em; - color: var(--color-text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bf-appearance-token-color-text-muted); transition: color $motion-fast $easing-standard; } @@ -218,7 +219,7 @@ &__item-beta { flex-shrink: 0; margin-left: $size-gap-2; - font-size: var(--font-size-xxs); + font-size: var(--bf-appearance-token-font-size-xxs); font-weight: 600; line-height: 1; letter-spacing: 0.02em; @@ -235,10 +236,10 @@ border: none; border-radius: $size-radius-base; background: transparent; - color: var(--color-text-secondary); + color: var(--bf-appearance-token-color-text-secondary); cursor: pointer; text-align: left; - font-size: var(--font-size-sm); + font-size: var(--bf-appearance-token-font-size-sm); font-weight: 400; width: 100%; position: relative; @@ -246,23 +247,23 @@ background $motion-fast $easing-standard; &:hover { - color: var(--color-text-primary); - background: var(--element-bg-soft); + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-soft); } &:active { - background: var(--element-bg-medium); + background: var(--bf-appearance-token-element-bg-medium); } &.is-active { - color: var(--color-text-primary); - background: transparent; + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-soft); font-weight: 500; box-shadow: none; } &:focus-visible { - outline: 2px solid var(--color-accent-500); + outline: 2px solid var(--bf-appearance-token-color-accent-500); outline-offset: -1px; } } diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index bfad63176f..e9022ba8b7 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -129,7 +129,7 @@ function highlightFirstMatch(text: string, query: string): React.ReactNode { return ( <> {text.slice(0, idx)} - + {text.slice(idx, idx + qi.length)} {text.slice(idx + qi.length)} @@ -316,14 +316,14 @@ const SettingsNav: React.FC = () => { } = useSettingsNav(); return ( -
-
+
+
{t('shared:features.settings')}
-
+
{
0 ? 0 : undefined} @@ -357,17 +359,18 @@ const SettingsNav: React.FC = () => { {isSearchMode ? ( <> {results.length === 0 ? ( -
+
{t('configCenter.searchNoResults')}
) : ( -
+
{results.map((row, index) => { const line = `${row.categoryLabel} › ${row.tabLabel}`; const active = activeTab === row.tabId; const highlighted = highlightedIndex === index; return ( - {!isCollapsed && ( -
+
{group.entries.map(entry => ( { const hasPendingChanges = Object.keys(pendingChanges).length > 0; return ( - + - + {/* Search + actions bar */} -
+
{ enterToSearch={false} clearable /> -
+
{hasPendingChanges && (
{saveError && ( -
{saveError}
+
{saveError}
)} {/* Shortcuts grouped by scope */} @@ -608,9 +612,16 @@ const KeyboardShortcutsTab: React.FC = () => { key={scope} title={t(SCOPE_LABEL_KEYS[scope])} > -
+
{showMergedTab && (
{ .join(' ')} key="tab-switch-merged" > -
+
{t('keyboard.shortcuts.tab.switchMerged')} {t('keyboard.shortcuts.tab.switchMergedHint')} {mergedTabConflict && ( @@ -630,7 +641,7 @@ const KeyboardShortcutsTab: React.FC = () => { )}
-
+
@@ -2351,8 +2351,8 @@ const AIModelConfig: React.FC = () => { return ( <> -
-
+
+
{ className="bitfun-ai-model-config__custom-request-body-row" >
-