From 29f4953eb20c61d8e6df93b55d56dbf9d9e51225 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 6 Aug 2026 03:22:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(hub-ui):=20add=20@devframes/hub-ui=20?= =?UTF-8?q?=E2=80=94=20the=20reference=20UI=20filling=20the=20hub's=20ui?= =?UTF-8?q?=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A port of Vite DevTools' web components onto @devframes/hub, prebuilt into two entries the hub serves through DevframeHubUi: - embedded.js — the floating DockEmbedded bootstrap (single self- contained ES module; always visible by design — visibility policy belongs to whoever authors an embedded entry) - a standalone viewer SPA (vanilla shell mounting DockStandalone, relative asset paths, served at the hub base) createUi() returns the slot object pointing at the built artifacts. The components (dock shell, panels, command palette, messages/toasts, views, json-render catalog) are Vue custom elements with shadow-root styles compiled ahead of time from the shared devframe design system (@antfu/design sage-green preset); imports are rewired from @vitejs/devtools-kit onto @devframes/hub's client runtime, the view-mode machinery is removed, and branding/element names/storage keys move to the devframes namespace. Storybook follows the repo's shared vue3-vite setup. Co-developed with Vite DevTools — the components descend from vitejs/devtools (MIT). --- alias.ts | 1 + packages/hub-ui/.storybook/main.ts | 25 + packages/hub-ui/.storybook/preview.ts | 48 ++ packages/hub-ui/package.json | 70 +++ packages/hub-ui/scripts/build-css.ts | 63 ++ packages/hub-ui/src/client/.generated/css.ts | 3 + .../src/client/components/DockEmbedded.ts | 17 + .../src/client/components/DockStandalone.ts | 18 + .../command-palette/CommandPalette.stories.ts | 47 ++ .../command-palette/CommandPalette.vue | 338 +++++++++++ .../CommandPaletteItem.stories.ts | 46 ++ .../command-palette/CommandPaletteItem.vue | 68 +++ .../KeybindingBadge.stories.ts | 35 ++ .../command-palette/KeybindingBadge.vue | 22 + .../components/display/Button.stories.ts | 79 +++ .../src/client/components/display/Button.vue | 68 +++ .../components/display/Confirm.stories.ts | 50 ++ .../src/client/components/display/Confirm.vue | 56 ++ .../components/display/HashBadge.stories.ts | 31 + .../client/components/display/HashBadge.vue | 17 + .../components/display/OtpInput.stories.ts | 57 ++ .../client/components/display/OtpInput.vue | 192 ++++++ .../display/ToastOverlay.stories.ts | 38 ++ .../components/display/ToastOverlay.vue | 58 ++ .../components/dock/ColorSchemeRoot.vue | 21 + .../client/components/dock/Dock.stories.ts | 152 +++++ .../src/client/components/dock/Dock.vue | 329 +++++++++++ .../client/components/dock/DockContextMenu.ts | 135 +++++ .../components/dock/DockEdge.stories.ts | 82 +++ .../src/client/components/dock/DockEdge.vue | 292 +++++++++ .../components/dock/DockEmbedded.stories.ts | 86 +++ .../client/components/dock/DockEmbedded.vue | 80 +++ .../client/components/dock/DockEntries.vue | 84 +++ .../dock/DockEntriesWithCategories.stories.ts | 77 +++ .../dock/DockEntriesWithCategories.vue | 38 ++ .../components/dock/DockEntry.stories.ts | 87 +++ .../src/client/components/dock/DockEntry.vue | 102 ++++ .../dock/DockGroupButton.stories.ts | 93 +++ .../components/dock/DockGroupButton.vue | 134 +++++ .../dock/DockGroupPopover.stories.ts | 107 ++++ .../components/dock/DockGroupPopover.vue | 57 ++ .../dock/DockGroupSidebar.stories.ts | 75 +++ .../components/dock/DockGroupSidebar.vue | 197 +++++++ .../components/dock/DockIcon.stories.ts | 48 ++ .../src/client/components/dock/DockIcon.vue | 33 ++ .../dock/DockOverflowButton.stories.ts | 49 ++ .../components/dock/DockOverflowButton.vue | 98 +++ .../components/dock/DockPanel.stories.ts | 74 +++ .../src/client/components/dock/DockPanel.vue | 212 +++++++ .../components/dock/DockPanelResizer.vue | 144 +++++ .../components/dock/DockStandalone.stories.ts | 63 ++ .../client/components/dock/DockStandalone.vue | 99 ++++ .../src/client/components/dock/dock-layout.ts | 235 ++++++++ .../components/floating/FloatingElements.vue | 28 + .../floating/FloatingPopover.stories.ts | 130 ++++ .../components/floating/FloatingPopover.ts | 177 ++++++ .../components/floating/floating-position.ts | 102 ++++ .../client/components/icons/BracketLeft.vue | 8 + .../client/components/icons/BracketRight.vue | 8 + .../client/components/icons/Brand.stories.ts | 31 + .../src/client/components/icons/BrandMark.vue | 7 + .../client/components/icons/BrandWordmark.vue | 8 + .../client/components/icons/IconifyIcon.vue | 49 ++ .../components/message/MessageItem.stories.ts | 41 ++ .../client/components/message/MessageItem.vue | 46 ++ .../message/MessageItemConstants.ts | 29 + .../views-builtin/SettingsAdvanced.vue | 169 ++++++ .../views-builtin/SettingsAppearance.vue | 131 ++++ .../views-builtin/SettingsDocks.vue | 557 ++++++++++++++++++ .../views-builtin/SettingsShortcuts.vue | 434 ++++++++++++++ .../ViewBuiltinClientAuthNotice.stories.ts | 29 + .../ViewBuiltinClientAuthNotice.vue | 133 +++++ .../ViewBuiltinSettings.stories.ts | 53 ++ .../views-builtin/ViewBuiltinSettings.vue | 75 +++ .../components/views/ViewCustomRenderer.vue | 41 ++ .../src/client/components/views/ViewEntry.vue | 80 +++ .../client/components/views/ViewIframe.vue | 339 +++++++++++ .../components/views/ViewJsonRender.vue | 109 ++++ .../components/views/ViewLauncher.stories.ts | 71 +++ .../client/components/views/ViewLauncher.vue | 149 +++++ packages/hub-ui/src/client/constants.ts | 45 ++ packages/hub-ui/src/client/embedded/index.ts | 56 ++ .../client/json-render/JsonRender.stories.ts | 261 ++++++++ .../client/json-render/components/Badge.ts | 38 ++ .../client/json-render/components/Button.ts | 41 ++ .../src/client/json-render/components/Card.ts | 71 +++ .../json-render/components/CodeBlock.ts | 51 ++ .../json-render/components/DataTable.ts | 81 +++ .../client/json-render/components/Divider.ts | 32 + .../src/client/json-render/components/Icon.ts | 23 + .../json-render/components/KeyValueTable.ts | 34 ++ .../src/client/json-render/components/Link.ts | 69 +++ .../client/json-render/components/Progress.ts | 46 ++ .../client/json-render/components/Select.ts | 244 ++++++++ .../client/json-render/components/Stack.ts | 59 ++ .../client/json-render/components/Switch.ts | 78 +++ .../src/client/json-render/components/Tabs.ts | 175 ++++++ .../src/client/json-render/components/Text.ts | 32 + .../json-render/components/TextInput.ts | 67 +++ .../src/client/json-render/components/Tree.ts | 54 ++ .../components/UnsupportedComponent.ts | 41 ++ .../client/json-render/components/tokens.ts | 77 +++ .../client/json-render/components/types.ts | 44 ++ .../hub-ui/src/client/json-render/registry.ts | 46 ++ packages/hub-ui/src/client/shims.d.ts | 6 + .../hub-ui/src/client/standalone/index.html | 28 + packages/hub-ui/src/client/standalone/main.ts | 38 ++ .../src/client/standalone/vite.config.ts | 16 + .../hub-ui/src/client/state/color-mode.ts | 30 + packages/hub-ui/src/client/state/commands.ts | 189 ++++++ packages/hub-ui/src/client/state/confirm.ts | 16 + packages/hub-ui/src/client/state/context.ts | 528 +++++++++++++++++ .../hub-ui/src/client/state/dock-settings.ts | 443 ++++++++++++++ packages/hub-ui/src/client/state/docks.ts | 86 +++ .../src/client/state/floating-tooltip.ts | 72 +++ .../hub-ui/src/client/state/keybindings.ts | 131 ++++ .../src/client/state/messages-client.ts | 77 +++ packages/hub-ui/src/client/state/messages.ts | 94 +++ packages/hub-ui/src/client/state/popup.ts | 245 ++++++++ packages/hub-ui/src/client/state/renderers.ts | 51 ++ .../hub-ui/src/client/state/setup-script.ts | 57 ++ packages/hub-ui/src/client/state/toasts.ts | 47 ++ .../hub-ui/src/client/stories/fixtures.ts | 112 ++++ .../hub-ui/src/client/stories/mock-context.ts | 127 ++++ .../src/client/stories/story-helpers.ts | 58 ++ packages/hub-ui/src/client/style.css | 135 +++++ packages/hub-ui/src/client/types.ts | 76 +++ .../hub-ui/src/client/utils/accent-color.ts | 24 + packages/hub-ui/src/client/utils/iconify.ts | 33 ++ .../hub-ui/src/client/utils/useIframePanes.ts | 83 +++ .../src/client/utils/useIsRpcTrusted.ts | 25 + packages/hub-ui/src/index.ts | 56 ++ packages/hub-ui/tsconfig.json | 11 + packages/hub-ui/tsdown.config.ts | 15 + packages/hub-ui/uno.config.ts | 35 ++ packages/hub-ui/vite.embedded.config.ts | 33 ++ pnpm-lock.yaml | 549 ++++++----------- pnpm-workspace.yaml | 3 + .../@devframes/hub-ui/index.snapshot.d.ts | 13 + .../@devframes/hub-ui/index.snapshot.js | 6 + tsconfig.base.json | 3 + turbo.json | 5 + 142 files changed, 12709 insertions(+), 376 deletions(-) create mode 100644 packages/hub-ui/.storybook/main.ts create mode 100644 packages/hub-ui/.storybook/preview.ts create mode 100644 packages/hub-ui/package.json create mode 100644 packages/hub-ui/scripts/build-css.ts create mode 100644 packages/hub-ui/src/client/.generated/css.ts create mode 100644 packages/hub-ui/src/client/components/DockEmbedded.ts create mode 100644 packages/hub-ui/src/client/components/DockStandalone.ts create mode 100644 packages/hub-ui/src/client/components/command-palette/CommandPalette.stories.ts create mode 100644 packages/hub-ui/src/client/components/command-palette/CommandPalette.vue create mode 100644 packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.stories.ts create mode 100644 packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.vue create mode 100644 packages/hub-ui/src/client/components/command-palette/KeybindingBadge.stories.ts create mode 100644 packages/hub-ui/src/client/components/command-palette/KeybindingBadge.vue create mode 100644 packages/hub-ui/src/client/components/display/Button.stories.ts create mode 100644 packages/hub-ui/src/client/components/display/Button.vue create mode 100644 packages/hub-ui/src/client/components/display/Confirm.stories.ts create mode 100644 packages/hub-ui/src/client/components/display/Confirm.vue create mode 100644 packages/hub-ui/src/client/components/display/HashBadge.stories.ts create mode 100644 packages/hub-ui/src/client/components/display/HashBadge.vue create mode 100644 packages/hub-ui/src/client/components/display/OtpInput.stories.ts create mode 100644 packages/hub-ui/src/client/components/display/OtpInput.vue create mode 100644 packages/hub-ui/src/client/components/display/ToastOverlay.stories.ts create mode 100644 packages/hub-ui/src/client/components/display/ToastOverlay.vue create mode 100644 packages/hub-ui/src/client/components/dock/ColorSchemeRoot.vue create mode 100644 packages/hub-ui/src/client/components/dock/Dock.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/Dock.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockContextMenu.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockEdge.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockEdge.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockEmbedded.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockEmbedded.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockEntries.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockEntriesWithCategories.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockEntriesWithCategories.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockEntry.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockEntry.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockGroupButton.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockGroupButton.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockGroupPopover.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockGroupPopover.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockGroupSidebar.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockIcon.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockIcon.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockOverflowButton.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockOverflowButton.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockPanel.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockPanel.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockPanelResizer.vue create mode 100644 packages/hub-ui/src/client/components/dock/DockStandalone.stories.ts create mode 100644 packages/hub-ui/src/client/components/dock/DockStandalone.vue create mode 100644 packages/hub-ui/src/client/components/dock/dock-layout.ts create mode 100644 packages/hub-ui/src/client/components/floating/FloatingElements.vue create mode 100644 packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts create mode 100644 packages/hub-ui/src/client/components/floating/FloatingPopover.ts create mode 100644 packages/hub-ui/src/client/components/floating/floating-position.ts create mode 100644 packages/hub-ui/src/client/components/icons/BracketLeft.vue create mode 100644 packages/hub-ui/src/client/components/icons/BracketRight.vue create mode 100644 packages/hub-ui/src/client/components/icons/Brand.stories.ts create mode 100644 packages/hub-ui/src/client/components/icons/BrandMark.vue create mode 100644 packages/hub-ui/src/client/components/icons/BrandWordmark.vue create mode 100644 packages/hub-ui/src/client/components/icons/IconifyIcon.vue create mode 100644 packages/hub-ui/src/client/components/message/MessageItem.stories.ts create mode 100644 packages/hub-ui/src/client/components/message/MessageItem.vue create mode 100644 packages/hub-ui/src/client/components/message/MessageItemConstants.ts create mode 100644 packages/hub-ui/src/client/components/views-builtin/SettingsAdvanced.vue create mode 100644 packages/hub-ui/src/client/components/views-builtin/SettingsAppearance.vue create mode 100644 packages/hub-ui/src/client/components/views-builtin/SettingsDocks.vue create mode 100644 packages/hub-ui/src/client/components/views-builtin/SettingsShortcuts.vue create mode 100644 packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.stories.ts create mode 100644 packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.vue create mode 100644 packages/hub-ui/src/client/components/views-builtin/ViewBuiltinSettings.stories.ts create mode 100644 packages/hub-ui/src/client/components/views-builtin/ViewBuiltinSettings.vue create mode 100644 packages/hub-ui/src/client/components/views/ViewCustomRenderer.vue create mode 100644 packages/hub-ui/src/client/components/views/ViewEntry.vue create mode 100644 packages/hub-ui/src/client/components/views/ViewIframe.vue create mode 100644 packages/hub-ui/src/client/components/views/ViewJsonRender.vue create mode 100644 packages/hub-ui/src/client/components/views/ViewLauncher.stories.ts create mode 100644 packages/hub-ui/src/client/components/views/ViewLauncher.vue create mode 100644 packages/hub-ui/src/client/constants.ts create mode 100644 packages/hub-ui/src/client/embedded/index.ts create mode 100644 packages/hub-ui/src/client/json-render/JsonRender.stories.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Badge.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Button.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Card.ts create mode 100644 packages/hub-ui/src/client/json-render/components/CodeBlock.ts create mode 100644 packages/hub-ui/src/client/json-render/components/DataTable.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Divider.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Icon.ts create mode 100644 packages/hub-ui/src/client/json-render/components/KeyValueTable.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Link.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Progress.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Select.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Stack.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Switch.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Tabs.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Text.ts create mode 100644 packages/hub-ui/src/client/json-render/components/TextInput.ts create mode 100644 packages/hub-ui/src/client/json-render/components/Tree.ts create mode 100644 packages/hub-ui/src/client/json-render/components/UnsupportedComponent.ts create mode 100644 packages/hub-ui/src/client/json-render/components/tokens.ts create mode 100644 packages/hub-ui/src/client/json-render/components/types.ts create mode 100644 packages/hub-ui/src/client/json-render/registry.ts create mode 100644 packages/hub-ui/src/client/shims.d.ts create mode 100644 packages/hub-ui/src/client/standalone/index.html create mode 100644 packages/hub-ui/src/client/standalone/main.ts create mode 100644 packages/hub-ui/src/client/standalone/vite.config.ts create mode 100644 packages/hub-ui/src/client/state/color-mode.ts create mode 100644 packages/hub-ui/src/client/state/commands.ts create mode 100644 packages/hub-ui/src/client/state/confirm.ts create mode 100644 packages/hub-ui/src/client/state/context.ts create mode 100644 packages/hub-ui/src/client/state/dock-settings.ts create mode 100644 packages/hub-ui/src/client/state/docks.ts create mode 100644 packages/hub-ui/src/client/state/floating-tooltip.ts create mode 100644 packages/hub-ui/src/client/state/keybindings.ts create mode 100644 packages/hub-ui/src/client/state/messages-client.ts create mode 100644 packages/hub-ui/src/client/state/messages.ts create mode 100644 packages/hub-ui/src/client/state/popup.ts create mode 100644 packages/hub-ui/src/client/state/renderers.ts create mode 100644 packages/hub-ui/src/client/state/setup-script.ts create mode 100644 packages/hub-ui/src/client/state/toasts.ts create mode 100644 packages/hub-ui/src/client/stories/fixtures.ts create mode 100644 packages/hub-ui/src/client/stories/mock-context.ts create mode 100644 packages/hub-ui/src/client/stories/story-helpers.ts create mode 100644 packages/hub-ui/src/client/style.css create mode 100644 packages/hub-ui/src/client/types.ts create mode 100644 packages/hub-ui/src/client/utils/accent-color.ts create mode 100644 packages/hub-ui/src/client/utils/iconify.ts create mode 100644 packages/hub-ui/src/client/utils/useIframePanes.ts create mode 100644 packages/hub-ui/src/client/utils/useIsRpcTrusted.ts create mode 100644 packages/hub-ui/src/index.ts create mode 100644 packages/hub-ui/tsconfig.json create mode 100644 packages/hub-ui/tsdown.config.ts create mode 100644 packages/hub-ui/uno.config.ts create mode 100644 packages/hub-ui/vite.embedded.config.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.js diff --git a/alias.ts b/alias.ts index 1b1f8806..d80bc99c 100644 --- a/alias.ts +++ b/alias.ts @@ -49,6 +49,7 @@ export const alias = { '@devframes/hub/node': r('hub/src/node/index.ts'), '@devframes/hub/types': r('hub/src/types/index.ts'), '@devframes/hub': r('hub/src/index.ts'), + '@devframes/hub-ui': r('hub-ui/src/index.ts'), '@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'), '@devframes/nuxt': r('nuxt/src/index.ts'), '@devframes/next/client': r('next/src/client.tsx'), diff --git a/packages/hub-ui/.storybook/main.ts b/packages/hub-ui/.storybook/main.ts new file mode 100644 index 00000000..cecaf8c1 --- /dev/null +++ b/packages/hub-ui/.storybook/main.ts @@ -0,0 +1,25 @@ +import type { StorybookConfig } from '@storybook/vue3-vite' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import { mergeConfig } from 'vite' +import { alias } from '../../../alias' + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-docs'], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: {}, + async viteFinal(config) { + return mergeConfig(config, { + resolve: { alias }, + plugins: [vue(), UnoCSS()], + // Dev tool reached from arbitrary hostnames (LAN IPs, tunnels, + // tailnets), e.g. when iframed by the storybook-hub example. + server: { allowedHosts: true }, + }) + }, +} +export default config diff --git a/packages/hub-ui/.storybook/preview.ts b/packages/hub-ui/.storybook/preview.ts new file mode 100644 index 00000000..ca839030 --- /dev/null +++ b/packages/hub-ui/.storybook/preview.ts @@ -0,0 +1,48 @@ +import type { Decorator, Preview } from '@storybook/vue3-vite' +import 'virtual:uno.css' +import '@antfu/design/styles.css' +import '../src/client/style.css' + +// Drive the shared `@antfu/design` tokens off the toolbar theme toggle: dark mode +// is the `.dark` class on ``, and the canvas takes the semantic +// `bg-base`/`color-base` surface — matching every other devframe surface. +function applyTheme(theme: string): void { + document.documentElement.classList.toggle('dark', theme !== 'light') + document.body.classList.add('bg-base', 'color-base', 'font-sans') +} + +const withTheme: Decorator = (story, context) => { + applyTheme(context.globals.theme ?? 'dark') + return { components: { story }, template: '' } +} + +const preview: Preview = { + parameters: { + layout: 'fullscreen', + controls: { + expanded: true, + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + globalTypes: { + theme: { + description: 'Color theme', + defaultValue: 'dark', + toolbar: { + title: 'Theme', + icon: 'contrast', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [withTheme], +} + +export default preview diff --git a/packages/hub-ui/package.json b/packages/hub-ui/package.json new file mode 100644 index 00000000..b29a8ea8 --- /dev/null +++ b/packages/hub-ui/package.json @@ -0,0 +1,70 @@ +{ + "name": "@devframes/hub-ui", + "type": "module", + "version": "0.8.1", + "description": "Reference UI for @devframes/hub \u2014 the floating dock and standalone viewer filling the hub's ui slot.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "packages/hub-ui", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devtools", + "devframe", + "hub", + "dock", + "ui" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run build:css && pnpm run build:node && pnpm run build:embedded && pnpm run build:standalone", + "build:css": "tsx scripts/build-css.ts", + "build:node": "tsdown", + "build:embedded": "vite build --config vite.embedded.config.ts", + "build:standalone": "vite build --config src/client/standalone/vite.config.ts", + "typecheck": "tsc --noEmit", + "prepack": "pnpm build", + "storybook": "storybook dev -p 6014", + "build-storybook": "storybook build" + }, + "peerDependencies": { + "@devframes/hub": "workspace:*", + "devframe": "workspace:*" + }, + "devDependencies": { + "@devframes/hub": "workspace:*", + "@iconify-json/ph": "catalog:frontend", + "@json-render/core": "catalog:deps", + "@json-render/vue": "catalog:frontend", + "@storybook/addon-docs": "catalog:storybook", + "@storybook/vue3-vite": "catalog:storybook", + "@types/node": "catalog:types", + "@unocss/reset": "catalog:frontend", + "@vitejs/plugin-vue": "catalog:build", + "@vueuse/core": "catalog:frontend", + "devframe": "workspace:*", + "dompurify": "catalog:frontend", + "fuse.js": "catalog:frontend", + "iframe-pane": "catalog:frontend", + "magic-string": "catalog:build", + "storybook": "catalog:storybook", + "tinyglobby": "catalog:deps", + "tsdown": "catalog:build", + "tsx": "catalog:build", + "unocss": "catalog:frontend", + "vite": "catalog:build", + "vue": "catalog:frontend" + } +} diff --git a/packages/hub-ui/scripts/build-css.ts b/packages/hub-ui/scripts/build-css.ts new file mode 100644 index 00000000..d738f744 --- /dev/null +++ b/packages/hub-ui/scripts/build-css.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { colors as c } from 'devframe/utils/colors' +import MagicString from 'magic-string' +import { glob } from 'tinyglobby' +import { createGenerator } from 'unocss' +import config from '../uno.config' + +// Compile the components' UnoCSS output ahead of time into a plain string +// module (`src/client/.generated/css.ts`) that `defineCustomElement` adopts +// into each shadow root — the dock stays fully styled inside any host page +// without a global stylesheet, and the host page's own styles can't leak in. +const SRC_DIR = fileURLToPath(new URL('../src/client', import.meta.url)) +const GLOBS = ['components/**/*.{ts,vue}', 'state/**/*.ts', 'embedded/**/*.ts', 'standalone/**/*.{ts,html}'] +// Story-only utility classes must not leak into the shipped stylesheet. +const IGNORE = ['**/*.stories.*', '**/__tests__/**'] +const USER_STYLE = join(SRC_DIR, 'style.css') +const GENERATED_CSS = join(SRC_DIR, '.generated/css.ts') + +export async function buildCSS(): Promise { + const require = createRequire(import.meta.url) + const reset = await fs.readFile(require.resolve('@unocss/reset/tailwind.css'), 'utf-8') + const files = await glob(GLOBS, { + cwd: SRC_DIR, + absolute: true, + ignore: IGNORE, + }) + + const generator = await createGenerator(config) + + const tokens = new Set() + for (const file of files) { + const content = await fs.readFile(file, 'utf-8') + await generator.applyExtractors(content, file, tokens) + } + + // The hand-written stylesheet may use `--at-apply` — run it through the + // configured transformers (directives, variant groups) before merging. + const userStyle = new MagicString(await fs.readFile(USER_STYLE, 'utf-8').catch(() => '')) + for (const transformer of generator.config.transformers ?? []) { + await transformer.transform(userStyle, USER_STYLE, { uno: generator } as any) + } + + const unoResult = await generator.generate(tokens) + const css = [ + reset, + userStyle.toString(), + unoResult.css, + ].join('\n') + + await fs.mkdir(join(SRC_DIR, '.generated'), { recursive: true }) + await fs.writeFile(GENERATED_CSS, [ + `/* eslint-disable eslint-comments/no-unlimited-disable */`, + `/* eslint-disable */`, + `export default ${JSON.stringify(String(css))}`, + '', + ].join('\n')) + console.log(`${c.green('✓')} CSS built (${files.length} sources, ${(css.length / 1024).toFixed(1)} kB)`) +} + +await buildCSS() diff --git a/packages/hub-ui/src/client/.generated/css.ts b/packages/hub-ui/src/client/.generated/css.ts new file mode 100644 index 00000000..8101d9b8 --- /dev/null +++ b/packages/hub-ui/src/client/.generated/css.ts @@ -0,0 +1,3 @@ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ +export default "/*\n1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)\n2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)\n2. [UnoCSS]: allow to override the default border color with css var `--un-default-border-color`\n*/\n\n*,\n::before,\n::after {\n box-sizing: border-box; /* 1 */\n border-width: 0; /* 2 */\n border-style: solid; /* 2 */\n border-color: var(--un-default-border-color, #e5e7eb); /* 2 */\n}\n\n::before,\n::after {\n --un-content: '';\n}\n\n/*\n1. Use a consistent sensible line-height in all browsers.\n2. Prevent adjustments of font size after orientation changes in iOS.\n3. Use a more readable tab size.\n4. Use the user's configured `sans` font-family by default.\n5. Use the user's configured `sans` font-feature-settings by default.\n6. Use the user's configured `sans` font-variation-settings by default.\n7. Disable tap highlights on iOS.\n*/\n\nhtml,\n:host {\n line-height: 1.5; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n -moz-tab-size: 4; /* 3 */\n tab-size: 4; /* 3 */\n font-family: ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"; /* 4 */\n font-feature-settings: normal; /* 5 */\n font-variation-settings: normal; /* 6 */\n -webkit-tap-highlight-color: transparent; /* 7 */\n}\n\n/*\n1. Remove the margin in all browsers.\n2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.\n*/\n\nbody {\n margin: 0; /* 1 */\n line-height: inherit; /* 2 */\n}\n\n/*\n1. Add the correct height in Firefox.\n2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)\n3. Ensure horizontal rules are visible by default.\n*/\n\nhr {\n height: 0; /* 1 */\n color: inherit; /* 2 */\n border-top-width: 1px; /* 3 */\n}\n\n/*\nAdd the correct text decoration in Chrome, Edge, and Safari.\n*/\n\nabbr:where([title]) {\n text-decoration: underline dotted;\n}\n\n/*\nRemove the default font size and weight for headings.\n*/\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-size: inherit;\n font-weight: inherit;\n}\n\n/*\nReset links to optimize for opt-in styling instead of opt-out.\n*/\n\na {\n color: inherit;\n text-decoration: inherit;\n}\n\n/*\nAdd the correct font weight in Edge and Safari.\n*/\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/*\n1. Use the user's configured `mono` font-family by default.\n2. Use the user's configured `mono` font-feature-settings by default.\n3. Use the user's configured `mono` font-variation-settings by default.\n4. Correct the odd `em` font sizing in all browsers.\n*/\n\ncode,\nkbd,\nsamp,\npre {\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace; /* 1 */\n font-feature-settings: normal; /* 2 */\n font-variation-settings: normal; /* 3 */\n font-size: 1em; /* 4 */\n}\n\n/*\nAdd the correct font size in all browsers.\n*/\n\nsmall {\n font-size: 80%;\n}\n\n/*\nPrevent `sub` and `sup` elements from affecting the line height in all browsers.\n*/\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/*\n1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)\n2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)\n3. Remove gaps between table borders by default.\n*/\n\ntable {\n text-indent: 0; /* 1 */\n border-color: inherit; /* 2 */\n border-collapse: collapse; /* 3 */\n}\n\n/*\n1. Change the font styles in all browsers.\n2. Remove the margin in Firefox and Safari.\n3. Remove default padding in all browsers.\n*/\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n font-family: inherit; /* 1 */\n font-feature-settings: inherit; /* 1 */\n font-variation-settings: inherit; /* 1 */\n font-size: 100%; /* 1 */\n font-weight: inherit; /* 1 */\n line-height: inherit; /* 1 */\n color: inherit; /* 1 */\n margin: 0; /* 2 */\n padding: 0; /* 3 */\n}\n\n/*\nRemove the inheritance of text transform in Edge and Firefox.\n*/\n\nbutton,\nselect {\n text-transform: none;\n}\n\n/*\n1. Correct the inability to style clickable types in iOS and Safari.\n2. Remove default button styles.\n*/\n\nbutton,\n[type='button'],\n[type='reset'],\n[type='submit'] {\n -webkit-appearance: button; /* 1 */\n background-color: transparent; /* 2 */\n background-image: none; /* 2 */\n}\n\n/*\nUse the modern Firefox focus style for all focusable elements.\n*/\n\n:-moz-focusring {\n outline: auto;\n}\n\n/*\nRemove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)\n*/\n\n:-moz-ui-invalid {\n box-shadow: none;\n}\n\n/*\nAdd the correct vertical alignment in Chrome and Firefox.\n*/\n\nprogress {\n vertical-align: baseline;\n}\n\n/*\nCorrect the cursor style of increment and decrement buttons in Safari.\n*/\n\n::-webkit-inner-spin-button,\n::-webkit-outer-spin-button {\n height: auto;\n}\n\n/*\n1. Correct the odd appearance in Chrome and Safari.\n2. Correct the outline style in Safari.\n*/\n\n[type='search'] {\n -webkit-appearance: textfield; /* 1 */\n outline-offset: -2px; /* 2 */\n}\n\n/*\nRemove the inner padding in Chrome and Safari on macOS.\n*/\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/*\n1. Correct the inability to style clickable types in iOS and Safari.\n2. Change font properties to `inherit` in Safari.\n*/\n\n::-webkit-file-upload-button {\n -webkit-appearance: button; /* 1 */\n font: inherit; /* 2 */\n}\n\n/*\nAdd the correct display in Chrome and Safari.\n*/\n\nsummary {\n display: list-item;\n}\n\n/*\nRemoves the default spacing for appropriate elements.\n*/\n\nblockquote,\ndl,\ndd,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\nhr,\nfigure,\np,\npre {\n margin: 0;\n}\n\nfieldset {\n margin: 0;\n padding: 0;\n}\n\nlegend {\n padding: 0;\n}\n\nol,\nul,\nmenu {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\ndialog {\n padding: 0;\n}\n\n/*\nPrevent resizing textareas horizontally by default.\n*/\n\ntextarea {\n resize: vertical;\n}\n\n/*\n1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)\n2. Set the default placeholder color to the user's configured gray 400 color.\n*/\n\ninput::placeholder,\ntextarea::placeholder {\n opacity: 1; /* 1 */\n color: #9ca3af; /* 2 */\n}\n\n/*\nSet the default cursor for buttons.\n*/\n\nbutton,\n[role=\"button\"] {\n cursor: pointer;\n}\n\n/*\nMake sure disabled buttons don't get the pointer cursor.\n*/\n\n:disabled {\n cursor: default;\n}\n\n/*\n1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)\n2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)\n This can trigger a poorly considered lint error in some tools but is included by design.\n*/\n\nimg,\nsvg,\nvideo,\ncanvas,\naudio,\niframe,\nembed,\nobject {\n display: block; /* 1 */\n vertical-align: middle; /* 2 */\n}\n\n/*\nConstrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)\n*/\n\nimg,\nvideo {\n max-width: 100%;\n height: auto;\n}\n\n/*\nMake elements with the HTML hidden attribute stay hidden by default.\n*/\n\n[hidden]:where(:not([hidden=\"until-found\"])) {\n display: none;\n}\n\n@property --un-ease{syntax:\"*\";inherits:false;}@property --un-bg-opacity{syntax:\"\";inherits:false;initial-value:100%;}@property --un-backdrop-blur{syntax:\"*\";inherits:false;}@property --un-backdrop-brightness{syntax:\"*\";inherits:false;}@property --un-backdrop-contrast{syntax:\"*\";inherits:false;}@property --un-backdrop-grayscale{syntax:\"*\";inherits:false;}@property --un-backdrop-hue-rotate{syntax:\"*\";inherits:false;}@property --un-backdrop-invert{syntax:\"*\";inherits:false;}@property --un-backdrop-opacity{syntax:\"*\";inherits:false;}@property --un-backdrop-saturate{syntax:\"*\";inherits:false;}@property --un-backdrop-sepia{syntax:\"*\";inherits:false;}@property --un-bg-opacity{syntax:\"\";inherits:false;initial-value:100%;}@property --un-inset-ring-color{syntax:\"*\";inherits:false;}@property --un-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}@property --un-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}@property --un-inset-shadow-color{syntax:\"*\";inherits:false;}@property --un-ring-color{syntax:\"*\";inherits:false;}@property --un-ring-inset{syntax:\"*\";inherits:false;}@property --un-ring-offset-color{syntax:\"*\";inherits:false;}@property --un-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}@property --un-ring-offset-width{syntax:\"\";inherits:false;initial-value:0px;}@property --un-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}@property --un-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}@property --un-shadow-color{syntax:\"*\";inherits:false;}@property --un-text-opacity{syntax:\"\";inherits:false;initial-value:100%;}:root {\n --un-text-opacity: 100%;\n}\n\n/*\n * Thin/transparent scrollbar, matching packages/ui/src/styles/global.css —\n * that stylesheet only reaches the Nuxt plugin apps, not this injected\n * webcomponents shell, which otherwise falls back to the browser default\n * (thick, opaque-gutter) scrollbar clashing with the translucent dock.\n * `:host`, not `:root` — this stylesheet is adopted into the dock's shadow\n * root (`defineCustomElement(..., { shadowRoot: true, styles: [css] })`),\n * where `:root` only ever matches the top-level document's .\n * Rules below are unscoped (not `#devframes-anchor ...`) since the\n * shadow boundary already keeps them from leaking onto the host page, and\n * scoping under the anchor's id would miss edge-docked mode entirely\n * (`DockEmbedded.vue` renders `DockEdge`/`#devframes-edge-panel` and\n * `Dock`/`#devframes-anchor` as mutually exclusive siblings).\n */\n:host {\n --app-scrollbar-size: 6px;\n --app-scrollbar-radius: 1px;\n --app-scrollbar-thumb: #8884;\n --app-scrollbar-thumb-hover: #8885;\n}\n\n::-webkit-scrollbar {\n width: var(--app-scrollbar-size);\n}\n\n::-webkit-scrollbar:horizontal {\n height: var(--app-scrollbar-size);\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n::-webkit-scrollbar-thumb {\n background-color: var(--app-scrollbar-thumb);\n transition: background 0.2s ease;\n border-radius: var(--app-scrollbar-radius);\n}\n\n::-webkit-scrollbar-thumb:hover {\n background-color: var(--app-scrollbar-thumb-hover);\n}\n\n::-webkit-scrollbar-track {\n border-radius: var(--app-scrollbar-radius);\n background: transparent;\n}\n\n#devframes-anchor {\n font-size:15px;font-family:var(--font-sans);width:calc(var(--spacing) * 0);transform-origin:center;box-sizing:border-box;position:fixed;z-index:2147483644;\n transform: translate(-50%, -50%) rotate(0);\n}\n\n#devframes-anchor #devframes-dock-container {\n display:flex;width:max-content;left:calc(var(--spacing) * 0);top:calc(var(--spacing) * 0);position:absolute;\n height: var(--devframes-dock-height, 40px);\n min-width: var(--devframes-dock-min-width, 100px);\n transform: translate(-50%, -50%);\n}\n\n#devframes-anchor.devframes-vertical #devframes-dock-container {\n transform: translate(-50%, -50%) rotate(90deg);\n transition-property:all;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));--un-duration:500ms;transition-duration:500ms;\n}\n\n#devframes-anchor #devframes-dock {\n margin:auto;border-radius:calc(infinity * 1px);height:100%;-webkit-user-select:none;user-select:none;touch-action:none;\n ;color:color-mix(in oklab, #333 var(--un-text-opacity), transparent);background-color:color-mix(in srgb, var(--colors-white) 80%, transparent);--un-backdrop-blur:blur(7px);-webkit-backdrop-filter:var(--un-backdrop-blur,) var(--un-backdrop-brightness,) var(--un-backdrop-contrast,) var(--un-backdrop-grayscale,) var(--un-backdrop-hue-rotate,) var(--un-backdrop-invert,) var(--un-backdrop-opacity,) var(--un-backdrop-saturate,) var(--un-backdrop-sepia,);backdrop-filter:var(--un-backdrop-blur,) var(--un-backdrop-brightness,) var(--un-backdrop-contrast,) var(--un-backdrop-grayscale,) var(--un-backdrop-hue-rotate,) var(--un-backdrop-invert,) var(--un-backdrop-opacity,) var(--un-backdrop-saturate,) var(--un-backdrop-sepia,);--un-shadow:0 1px 3px 0 var(--un-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);\n transition-property:all;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));--un-duration:500ms;transition-duration:500ms;\n height: var(--devframes-dock-height, 40px);\n width: calc-size(max-content, size);\n}.dark #devframes-anchor #devframes-dock{color:color-mix(in srgb, var(--colors-white) var(--un-text-opacity), transparent);background-color:color-mix(in oklab, #111 80%, transparent);}@supports (color: color-mix(in lab, red, red)){.dark #devframes-anchor #devframes-dock{color:color-mix(in oklab, var(--colors-white) var(--un-text-opacity), transparent);}}@supports (color: color-mix(in lab, red, red)){#devframes-anchor #devframes-dock{background-color:color-mix(in oklab, var(--colors-white) 80%, transparent);}}\n\n#devframes-anchor.devframes-minimized #devframes-dock {\n padding-inline:calc(var(--spacing) * 0);padding-block:2px;\n width: var(--devframes-dock-minimized-size, 22px);\n height: var(--devframes-dock-minimized-size, 22px);\n}\n\n#devframes-anchor.devframes-minimized .devframes-dock-bracket {\n opacity:50%;width:calc(var(--spacing) * 1.5);\n}\n\n#devframes-anchor:hover #devframes-glowing {\n opacity:60%;\n}\n\n#devframes-anchor #devframes-glowing {\n border-radius:calc(infinity * 1px);opacity:0%;pointer-events:none;transition-property:all;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));--un-duration:1000ms;transition-duration:1000ms;--un-ease:var(--ease-out);transition-timing-function:var(--ease-out);left:calc(var(--spacing) * 0);top:calc(var(--spacing) * 0);position:absolute;\n width: var(--devframes-dock-glow-size, 160px);\n height: var(--devframes-dock-glow-size, 160px);\n filter: blur(var(--devframes-dock-glow-blur, 60px));\n transform: translate(-50%, -50%);\n background-image: linear-gradient(45deg, #61d9ff, #6b84fd, #715ebd);\n}\n\n@media print {\n #devframes-anchor {\n display:none;\n }\n}\n\n.devframes-resize-handle-horizontal {\n margin-block:-5px;border-radius:var(--radius-md);height:10px;cursor:ns-resize;left:6px;right:6px;position:absolute;\n}\n.devframes-resize-handle-vertical {\n margin-inline:-5px;border-radius:var(--radius-md);width:10px;cursor:ew-resize;top:6px;bottom:calc(var(--spacing) * 0);position:absolute;\n}\n.devframes-resize-handle-corner {\n margin:-6px;border-radius:var(--radius-md);width:14px;height:14px;position:absolute;\n}\n.devframes-resize-handle {\n z-index:30;\n}\n.devframes-resize-handle:hover {\n ;background-color:color-mix(in srgb, var(--colors-gray-400) 10%, transparent);\n}@supports (color: color-mix(in lab, red, red)){.devframes-resize-handle:hover{background-color:color-mix(in oklab, var(--colors-gray-400) 10%, transparent);}}\n\n@keyframes devframes-shake {\n 0%, 100% { transform: translateX(0); }\n 20%, 60% { transform: translateX(-5px); }\n 40%, 80% { transform: translateX(5px); }\n}\n.devframes-shake {\n animation: devframes-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97);\n}\n@media (prefers-reduced-motion: reduce) {\n .devframes-shake {\n animation: none;\n }\n}\n\n/* layer: properties */\n@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))){*, ::before, ::after, ::backdrop{--un-bg-opacity:100%;--un-border-opacity:100%;--un-caret-opacity:100%;--un-ease:initial;--un-fill-opacity:100%;--un-leading:initial;--un-ring-opacity:100%;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-space-y-reverse:initial;--un-text-opacity:100%;--un-translate-x:initial;--un-translate-y:initial;--un-translate-z:initial;}}\n@property --un-text-opacity{syntax:\"\";inherits:false;initial-value:100%;}\n@property --un-leading{syntax:\"*\";inherits:false;}\n@property --un-outline-style{syntax:\"*\";inherits:false;initial-value:solid;}\n@property --un-caret-opacity{syntax:\"\";inherits:false;initial-value:100%;}\n@property --un-border-opacity{syntax:\"\";inherits:false;initial-value:100%;}\n@property --un-bg-opacity{syntax:\"\";inherits:false;initial-value:100%;}\n@property --un-ring-opacity{syntax:\"\";inherits:false;initial-value:100%;}\n@property --un-inset-ring-color{syntax:\"*\";inherits:false;}\n@property --un-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}\n@property --un-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}\n@property --un-inset-shadow-color{syntax:\"*\";inherits:false;}\n@property --un-ring-color{syntax:\"*\";inherits:false;}\n@property --un-ring-inset{syntax:\"*\";inherits:false;}\n@property --un-ring-offset-color{syntax:\"*\";inherits:false;}\n@property --un-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}\n@property --un-ring-offset-width{syntax:\"\";inherits:false;initial-value:0px;}\n@property --un-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}\n@property --un-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000;}\n@property --un-shadow-color{syntax:\"*\";inherits:false;}\n@property --un-translate-x{syntax:\"*\";inherits:false;initial-value:0;}\n@property --un-translate-y{syntax:\"*\";inherits:false;initial-value:0;}\n@property --un-translate-z{syntax:\"*\";inherits:false;initial-value:0;}\n@property --un-scale-x{syntax:\"*\";inherits:false;initial-value:1;}\n@property --un-scale-y{syntax:\"*\";inherits:false;initial-value:1;}\n@property --un-scale-z{syntax:\"*\";inherits:false;initial-value:1;}\n@property --un-ease{syntax:\"*\";inherits:false;}\n@property --un-fill-opacity{syntax:\"\";inherits:false;initial-value:100%;}\n@property --un-backdrop-blur{syntax:\"*\";inherits:false;}\n@property --un-backdrop-brightness{syntax:\"*\";inherits:false;}\n@property --un-backdrop-contrast{syntax:\"*\";inherits:false;}\n@property --un-backdrop-grayscale{syntax:\"*\";inherits:false;}\n@property --un-backdrop-hue-rotate{syntax:\"*\";inherits:false;}\n@property --un-backdrop-invert{syntax:\"*\";inherits:false;}\n@property --un-backdrop-opacity{syntax:\"*\";inherits:false;}\n@property --un-backdrop-saturate{syntax:\"*\";inherits:false;}\n@property --un-backdrop-sepia{syntax:\"*\";inherits:false;}\n@property --un-blur{syntax:\"*\";inherits:false;}\n@property --un-brightness{syntax:\"*\";inherits:false;}\n@property --un-contrast{syntax:\"*\";inherits:false;}\n@property --un-drop-shadow{syntax:\"*\";inherits:false;}\n@property --un-grayscale{syntax:\"*\";inherits:false;}\n@property --un-hue-rotate{syntax:\"*\";inherits:false;}\n@property --un-invert{syntax:\"*\";inherits:false;}\n@property --un-saturate{syntax:\"*\";inherits:false;}\n@property --un-sepia{syntax:\"*\";inherits:false;}\n@property --un-numeric-figure{syntax:\"*\";inherits:false;}\n@property --un-numeric-fraction{syntax:\"*\";inherits:false;}\n@property --un-numeric-spacing{syntax:\"*\";inherits:false;}\n@property --un-ordinal{syntax:\"*\";inherits:false;}\n@property --un-slashed-zero{syntax:\"*\";inherits:false;}\n@property --un-space-y-reverse{syntax:\"*\";inherits:false;initial-value:0;}\n/* layer: theme */\n:root, :host { --spacing: 0.25rem; --font-sans: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,\"Noto Sans\",sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\"; --default-transition-timingFunction: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --ease-out: cubic-bezier(0, 0, 0.2, 1); --radius-md: 0.375rem; --colors-gray-400: oklch(70.7% 0.022 261.325); --colors-white: #fff; --container-lg: 32rem; --radius-lg: 0.5rem; --radius-DEFAULT: 0.25rem; --font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace; --colors-primary-DEFAULT: #3a6a45; --colors-blue-DEFAULT: oklch(70.7% 0.165 254.624); --colors-red-500: oklch(63.7% 0.237 25.331); --radius-xl: 0.75rem; --fontWeight-medium: 500; --colors-black: #000; --colors-primary-500: #578861; --ease-in: cubic-bezier(0.4, 0, 1, 1); --colors-primary-600: #40704b; --colors-primary-700: #295935; --colors-red-600: oklch(57.7% 0.245 27.325); --colors-primary-400: #75a67e; --colors-amber-DEFAULT: oklch(82.8% 0.189 84.429); --colors-orange-DEFAULT: oklch(75% 0.183 55.934); --text-xs-fontSize: 0.75rem; --text-xs-lineHeight: 1rem; --text-sm-fontSize: 0.875rem; --text-sm-lineHeight: 1.25rem; --text-2xl-fontSize: 1.5rem; --text-2xl-lineHeight: 2rem; --text-base-fontSize: 1rem; --text-base-lineHeight: 1.5rem; --colors-primary-300: #93c69d; --colors-red-400: oklch(70.4% 0.191 22.216); --leading-none: 1; --colors-neutral-800: oklch(26.9% 0 0); --colors-neutral-200: oklch(92.2% 0 0); --fontWeight-bold: 700; --tracking-wide: 0.025em; --colors-gray-DEFAULT: oklch(70.7% 0.022 261.325); --tracking-tight: -0.025em; --colors-red-DEFAULT: oklch(70.4% 0.191 22.216); --colors-purple-DEFAULT: oklch(71.4% 0.203 305.504); --leading-relaxed: 1.625; --colors-green-DEFAULT: oklch(79.2% 0.209 151.711); --text-xl-fontSize: 1.25rem; --text-xl-lineHeight: 1.75rem; --default-font-family: var(--font-sans); --default-monoFont-family: var(--font-mono); }\n/* layer: base */\n *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' ); font-feature-settings: var(--default-font-featureSettings, normal); font-variation-settings: var(--default-font-variationSettings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var( --default-monoFont-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace ); font-feature-settings: var(--default-monoFont-featureSettings, normal); font-variation-settings: var(--default-monoFont-variationSettings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden~='until-found'])) { display: none !important; }\n/* layer: preflights */\n*,::before,::after{border-color:#8882}\n/* layer: icons */\n.i-ph-arrow-clockwise{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-arrow-clockwise-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88' opacity='.2'/%3E%3Cpath d='M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-arrow-counter-clockwise{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-arrow-square-out-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M184 80v128a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h128a8 8 0 0 1 8 8' opacity='.2'/%3E%3Cpath d='M224 104a8 8 0 0 1-16 0V59.32l-66.33 66.34a8 8 0 0 1-11.32-11.32L196.68 48H152a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8Zm-40 24a8 8 0 0 0-8 8v72H48V80h72a8 8 0 0 0 0-16H48a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-72a8 8 0 0 0-8-8'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-arrows-counter-clockwise-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88' opacity='.2'/%3E%3Cpath d='M88 104H40a8 8 0 0 1-8-8V48a8 8 0 0 1 16 0v28.69l14.63-14.63A95.43 95.43 0 0 1 130 33.94h.53a95.36 95.36 0 0 1 67.07 27.33a8 8 0 0 1-11.18 11.44a79.52 79.52 0 0 0-55.89-22.77h-.45a79.56 79.56 0 0 0-56.14 23.43L59.31 88H88a8 8 0 0 1 0 16m128 48h-48a8 8 0 0 0 0 16h28.69l-14.63 14.63a79.56 79.56 0 0 1-56.13 23.43h-.45a79.52 79.52 0 0 1-55.89-22.77a8 8 0 1 0-11.18 11.44a95.36 95.36 0 0 0 67.07 27.33h.52a95.43 95.43 0 0 0 67.36-28.12L208 179.31V208a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-arrows-out-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M208 48v160H48V48Z' opacity='.2'/%3E%3Cpath d='M216 48v48a8 8 0 0 1-16 0V67.31l-42.34 42.35a8 8 0 0 1-11.32-11.32L188.69 56H160a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8M98.34 146.34L56 188.69V160a8 8 0 0 0-16 0v48a8 8 0 0 0 8 8h48a8 8 0 0 0 0-16H67.31l42.35-42.34a8 8 0 0 0-11.32-11.32M208 152a8 8 0 0 0-8 8v28.69l-42.34-42.35a8 8 0 0 0-11.32 11.32L188.69 200H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M67.31 56H96a8 8 0 0 0 0-16H48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31l42.34 42.35a8 8 0 0 0 11.32-11.32Z'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-cards-three-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M216 104v96a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8v-96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8' opacity='.2'/%3E%3Cpath d='M208 88H48a16 16 0 0 0-16 16v96a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-96a16 16 0 0 0-16-16m0 112H48v-96h160zM48 64a8 8 0 0 1 8-8h144a8 8 0 0 1 0 16H56a8 8 0 0 1-8-8m16-32a8 8 0 0 1 8-8h112a8 8 0 0 1 0 16H72a8 8 0 0 1-8-8'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-caret-down{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='m213.66 101.66l-80 80a8 8 0 0 1-11.32 0l-80-80a8 8 0 0 1 11.32-11.32L128 164.69l74.34-74.35a8 8 0 0 1 11.32 11.32'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-caret-left{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M165.66 202.34a8 8 0 0 1-11.32 11.32l-80-80a8 8 0 0 1 0-11.32l80-80a8 8 0 0 1 11.32 11.32L91.31 128Z'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-caret-right{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='m181.66 133.66l-80 80a8 8 0 0 1-11.32-11.32L164.69 128L90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-caret-up{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M213.66 165.66a8 8 0 0 1-11.32 0L128 91.31l-74.34 74.35a8 8 0 0 1-11.32-11.32l80-80a8 8 0 0 1 11.32 0l80 80a8 8 0 0 1 0 11.32'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-check-bold{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='m232.49 80.49l-128 128a12 12 0 0 1-17 0l-56-56a12 12 0 1 1 17-17L96 183L215.51 63.51a12 12 0 0 1 17 17Z'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-circle-notch{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-dots-six-vertical{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-dots-three-outline-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M152 128a24 24 0 1 1-24-24a24 24 0 0 1 24 24M48 104a24 24 0 1 0 24 24a24 24 0 0 0-24-24m160 0a24 24 0 1 0 24 24a24 24 0 0 0-24-24' opacity='.2'/%3E%3Cpath d='M128 96a32 32 0 1 0 32 32a32 32 0 0 0-32-32m0 48a16 16 0 1 1 16-16a16 16 0 0 1-16 16M48 96a32 32 0 1 0 32 32a32 32 0 0 0-32-32m0 48a16 16 0 1 1 16-16a16 16 0 0 1-16 16m160-48a32 32 0 1 0 32 32a32 32 0 0 0-32-32m0 48a16 16 0 1 1 16-16a16 16 0 0 1-16 16'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-eye-slash{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M53.92 34.62a8 8 0 1 0-11.84 10.76l19.24 21.17C25 88.84 9.38 123.2 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208a127.1 127.1 0 0 0 52.07-10.83l22 24.21a8 8 0 1 0 11.84-10.76Zm47.33 75.84l41.67 45.85a32 32 0 0 1-41.67-45.85M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.2 133.2 0 0 1 25 128c4.69-8.79 19.66-33.39 47.35-49.38l18 19.75a48 48 0 0 0 63.66 70l14.73 16.2A112 112 0 0 1 128 192m6-95.43a8 8 0 0 1 3-15.72a48.16 48.16 0 0 1 38.77 42.64a8 8 0 0 1-7.22 8.71a6 6 0 0 1-.75 0a8 8 0 0 1-8-7.26A32.09 32.09 0 0 0 134 96.57m113.28 34.69c-.42.94-10.55 23.37-33.36 43.8a8 8 0 1 1-10.67-11.92a132.8 132.8 0 0 0 27.8-35.14a133.2 133.2 0 0 0-23.12-30.77C185.67 75.19 158.78 64 128 64a118.4 118.4 0 0 0-19.36 1.57A8 8 0 1 1 106 49.79A134 134 0 0 1 128 48c34.88 0 66.57 13.26 91.66 38.35c18.83 18.83 27.3 37.62 27.65 38.41a8 8 0 0 1 0 6.5Z'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-eye-slash-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40' opacity='.2'/%3E%3Cpath d='M53.92 34.62a8 8 0 1 0-11.84 10.76l19.24 21.17C25 88.84 9.38 123.2 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208a127.1 127.1 0 0 0 52.07-10.83l22 24.21a8 8 0 1 0 11.84-10.76Zm47.33 75.84l41.67 45.85a32 32 0 0 1-41.67-45.85M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.2 133.2 0 0 1 25 128c4.69-8.79 19.66-33.39 47.35-49.38l18 19.75a48 48 0 0 0 63.66 70l14.73 16.2A112 112 0 0 1 128 192m6-95.43a8 8 0 0 1 3-15.72a48.16 48.16 0 0 1 38.77 42.64a8 8 0 0 1-7.22 8.71a6 6 0 0 1-.75 0a8 8 0 0 1-8-7.26A32.09 32.09 0 0 0 134 96.57m113.28 34.69c-.42.94-10.55 23.37-33.36 43.8a8 8 0 1 1-10.67-11.92a132.8 132.8 0 0 0 27.8-35.14a133.2 133.2 0 0 0-23.12-30.77C185.67 75.19 158.78 64 128 64a118.4 118.4 0 0 0-19.36 1.57A8 8 0 1 1 106 49.79A134 134 0 0 1 128 48c34.88 0 66.57 13.26 91.66 38.35c18.83 18.83 27.3 37.62 27.65 38.41a8 8 0 0 1 0 6.5Z'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-globe{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-keyboard-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M232 64v128a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h192a8 8 0 0 1 8 8' opacity='.2'/%3E%3Cpath d='M224 48H32a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m0 144H32V64h192zm-16-64a8 8 0 0 1-8 8H56a8 8 0 0 1 0-16h144a8 8 0 0 1 8 8m0-32a8 8 0 0 1-8 8H56a8 8 0 0 1 0-16h144a8 8 0 0 1 8 8M72 160a8 8 0 0 1-8 8h-8a8 8 0 0 1 0-16h8a8 8 0 0 1 8 8m96 0a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m40 0a8 8 0 0 1-8 8h-8a8 8 0 0 1 0-16h8a8 8 0 0 1 8 8'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-laptop-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M216 72v104H40V72a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16' opacity='.2'/%3E%3Cpath d='M232 168h-8V72a24 24 0 0 0-24-24H56a24 24 0 0 0-24 24v96h-8a8 8 0 0 0-8 8v16a24 24 0 0 0 24 24h176a24 24 0 0 0 24-24v-16a8 8 0 0 0-8-8M48 72a8 8 0 0 1 8-8h144a8 8 0 0 1 8 8v96H48Zm176 120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-8h192ZM152 88a8 8 0 0 1-8 8h-32a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-layout-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M104 104v104H40a8 8 0 0 1-8-8v-96Z' opacity='.2'/%3E%3Cpath d='M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v40H40V56ZM40 112h56v88H40Zm176 88H112v-88h104z'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-magnifying-glass-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80' opacity='.2'/%3E%3Cpath d='m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-moon-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M227.89 147.89A96 96 0 1 1 108.11 28.11a96.09 96.09 0 0 0 119.78 119.78' opacity='.2'/%3E%3Cpath d='M233.54 142.23a8 8 0 0 0-8-2a88.08 88.08 0 0 1-109.8-109.8a8 8 0 0 0-10-10a104.84 104.84 0 0 0-52.91 37A104 104 0 0 0 136 224a103.1 103.1 0 0 0 62.52-20.88a104.84 104.84 0 0 0 37-52.91a8 8 0 0 0-1.98-7.98m-44.64 48.11A88 88 0 0 1 65.66 67.11a89 89 0 0 1 31.4-26A106 106 0 0 0 96 56a104.11 104.11 0 0 0 104 104a106 106 0 0 0 14.92-1.06a89 89 0 0 1-26.02 31.4'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-paint-brush-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M224 32c0 32.81-31.64 67.43-58.64 91.05A84.4 84.4 0 0 0 133 90.64c23.57-27 58.19-58.64 91-58.64' opacity='.2'/%3E%3Cpath d='M232 32a8 8 0 0 0-8-8c-44.08 0-89.31 49.71-114.43 82.63A60 60 0 0 0 32 164c0 30.88-19.54 44.73-20.47 45.37A8 8 0 0 0 16 224h76a60 60 0 0 0 57.37-77.57C182.3 121.31 232 76.08 232 32M92 208H34.63C41.38 198.41 48 183.92 48 164a44 44 0 1 1 44 44m32.42-94.45q5.14-6.66 10.09-12.55A76.2 76.2 0 0 1 155 121.49q-5.9 4.94-12.55 10.09a60.5 60.5 0 0 0-18.03-18.03m42.7-2.68a92.6 92.6 0 0 0-22-22c31.78-34.53 55.75-45 69.9-47.91c-2.85 14.16-13.37 38.13-47.9 69.91'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-push-pin{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='m235.32 81.37l-60.69-60.68a16 16 0 0 0-22.63 0l-53.63 53.8c-10.66-3.34-35-7.37-60.4 13.14a16 16 0 0 0-1.29 23.78L85 159.71l-42.66 42.63a8 8 0 0 0 11.32 11.32L96.29 171l48.29 48.29A16 16 0 0 0 155.9 224h1.13a15.93 15.93 0 0 0 11.64-6.33c19.64-26.1 17.75-47.32 13.19-60L235.33 104a16 16 0 0 0-.01-22.63M224 92.69l-57.27 57.46a8 8 0 0 0-1.49 9.22c9.46 18.93-1.8 38.59-9.34 48.62L48 100.08c12.08-9.74 23.64-12.31 32.48-12.31A40.1 40.1 0 0 1 96.81 91a8 8 0 0 0 9.25-1.51L163.32 32L224 92.68Z'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-push-pin-fill{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='m235.33 104l-53.47 53.65c4.56 12.67 6.45 33.89-13.19 60A15.93 15.93 0 0 1 157 224h-1.13a16 16 0 0 1-11.32-4.69L96.29 171l-42.63 42.66a8 8 0 0 1-11.32-11.32L85 159.71l-48.3-48.3A16 16 0 0 1 38 87.63c25.42-20.51 49.75-16.48 60.4-13.14L152 20.7a16 16 0 0 1 22.63 0l60.69 60.68a16 16 0 0 1 .01 22.62'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-rocket-launch-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M184 120v61.65a8 8 0 0 1-2.34 5.65l-34.35 34.35a8 8 0 0 1-13.57-4.53L128 176Zm-48-48H74.35a8 8 0 0 0-5.65 2.34l-34.35 34.35a8 8 0 0 0 4.53 13.57L80 128ZM40 216c37.65 0 50.69-19.69 54.56-28.18l-26.38-26.38C59.69 165.31 40 178.35 40 216' opacity='.2'/%3E%3Cpath d='M223.85 47.12a16 16 0 0 0-15-15c-12.58-.75-44.73.4-71.41 27.07L132.69 64H74.36A15.9 15.9 0 0 0 63 68.68L28.7 103a16 16 0 0 0 9.07 27.16l38.47 5.37l44.21 44.21l5.37 38.49a15.94 15.94 0 0 0 10.78 12.92a16.1 16.1 0 0 0 5.1.83a15.9 15.9 0 0 0 11.3-4.68l34.32-34.3a15.9 15.9 0 0 0 4.68-11.36v-58.33l4.77-4.77c26.68-26.68 27.83-58.83 27.08-71.42M74.36 80h42.33l-39.53 39.52L40 114.34Zm74.41-9.45a76.65 76.65 0 0 1 59.11-22.47a76.46 76.46 0 0 1-22.42 59.16L128 164.68L91.32 128ZM176 181.64L141.67 216l-5.19-37.17L176 139.31Zm-74.16 9.5C97.34 201 82.29 224 40 224a8 8 0 0 1-8-8c0-42.29 23-57.34 32.86-61.85a8 8 0 0 1 6.64 14.56c-6.43 2.93-20.62 12.36-23.12 38.91c26.55-2.5 36-16.69 38.91-23.12a8 8 0 1 1 14.56 6.64Z'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-shield-check-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M216 56v56c0 96-88 120-88 120s-88-24-88-120V56a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8' opacity='.2'/%3E%3Cpath d='M208 40H48a16 16 0 0 0-16 16v56c0 52.72 25.52 84.67 46.93 102.19c23.06 18.86 46 25.26 47 25.53a8 8 0 0 0 4.2 0c1-.27 23.91-6.67 47-25.53C198.48 196.67 224 164.72 224 112V56a16 16 0 0 0-16-16m0 72c0 37.07-13.66 67.16-40.6 89.42a129.3 129.3 0 0 1-39.4 22.2a128.3 128.3 0 0 1-38.92-21.81C61.82 179.51 48 149.3 48 112V56h160ZM82.34 141.66a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0Z'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-sign-out-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M224 56v144a16 16 0 0 1-16 16H48V40h160a16 16 0 0 1 16 16' opacity='.2'/%3E%3Cpath d='M120 216a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V40a8 8 0 0 1 8-8h64a8 8 0 0 1 0 16H56v160h56a8 8 0 0 1 8 8m109.66-93.66l-40-40a8 8 0 0 0-11.32 11.32L204.69 120H112a8 8 0 0 0 0 16h92.69l-26.35 26.34a8 8 0 0 0 11.32 11.32l40-40a8 8 0 0 0 0-11.32'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-spinner-gap-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96' opacity='.2'/%3E%3Cpath d='M136 32v32a8 8 0 0 1-16 0V32a8 8 0 0 1 16 0m88 88h-32a8 8 0 0 0 0 16h32a8 8 0 0 0 0-16m-45.09 47.6a8 8 0 0 0-11.31 11.31l22.62 22.63a8 8 0 0 0 11.32-11.32ZM128 184a8 8 0 0 0-8 8v32a8 8 0 0 0 16 0v-32a8 8 0 0 0-8-8m-50.91-16.4l-22.63 22.62a8 8 0 0 0 11.32 11.32l22.62-22.63a8 8 0 0 0-11.31-11.31M72 128a8 8 0 0 0-8-8H32a8 8 0 0 0 0 16h32a8 8 0 0 0 8-8m-6.22-73.54a8 8 0 0 0-11.32 11.32L77.09 88.4A8 8 0 0 0 88.4 77.09Z'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-square-half-bottom-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M208 128v72a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8v-72Z' opacity='.2'/%3E%3Cpath d='M200 40H56a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v64H56V56Zm0 144H56v-64h144z'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-sun-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M184 128a56 56 0 1 1-56-56a56 56 0 0 1 56 56' opacity='.2'/%3E%3Cpath d='M120 40V16a8 8 0 0 1 16 0v24a8 8 0 0 1-16 0m72 88a64 64 0 1 1-64-64a64.07 64.07 0 0 1 64 64m-16 0a48 48 0 1 0-48 48a48.05 48.05 0 0 0 48-48M58.34 69.66a8 8 0 0 0 11.32-11.32l-16-16a8 8 0 0 0-11.32 11.32Zm0 116.68l-16 16a8 8 0 0 0 11.32 11.32l16-16a8 8 0 0 0-11.32-11.32M192 72a8 8 0 0 0 5.66-2.34l16-16a8 8 0 0 0-11.32-11.32l-16 16A8 8 0 0 0 192 72m5.66 114.34a8 8 0 0 0-11.32 11.32l16 16a8 8 0 0 0 11.32-11.32ZM48 128a8 8 0 0 0-8-8H16a8 8 0 0 0 0 16h24a8 8 0 0 0 8-8m80 80a8 8 0 0 0-8 8v24a8 8 0 0 0 16 0v-24a8 8 0 0 0-8-8m112-88h-24a8 8 0 0 0 0 16h24a8 8 0 0 0 0-16'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-warning-duotone,\n.i-ph\\:warning-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91' opacity='.2'/%3E%3Cpath d='M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-warning-fill{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72M120 104a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0Zm8 88a12 12 0 1 1 12-12a12 12 0 0 1-12 12'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-wrench-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M224 96a64 64 0 0 1-94.94 56L73 217a24 24 0 0 1-34-34l65-56.06a64 64 0 0 1 80-90.29L144 80l5.66 26.34L176 112l43.35-40A63.8 63.8 0 0 1 224 96' opacity='.2'/%3E%3Cpath d='M226.76 69a8 8 0 0 0-12.84-2.88l-40.3 37.19l-17.23-3.7l-3.7-17.23l37.19-40.3A8 8 0 0 0 187 29.24A72 72 0 0 0 88 96a72.3 72.3 0 0 0 6 28.94L33.79 177c-.15.12-.29.26-.43.39a32 32 0 0 0 45.26 45.26c.13-.13.27-.28.39-.42L131.06 162A72 72 0 0 0 232 96a71.6 71.6 0 0 0-5.24-27M160 152a56.14 56.14 0 0 1-27.07-7a8 8 0 0 0-9.92 1.77l-55.9 64.74a16 16 0 0 1-22.62-22.62L109.18 133a8 8 0 0 0 1.77-9.93a56 56 0 0 1 58.36-82.31l-31.2 33.81a8 8 0 0 0-1.94 7.1l5.66 26.33a8 8 0 0 0 6.14 6.14l26.35 5.66a8 8 0 0 0 7.1-1.94l33.81-31.2A56.06 56.06 0 0 1 160 152'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph-x{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph\\:bug-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M208 128v16a80 80 0 0 1-160 0v-16Z' opacity='.2'/%3E%3Cpath d='M144 92a12 12 0 1 1 12 12a12 12 0 0 1-12-12m-44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m116 64a87.8 87.8 0 0 1-3 23l22.24 9.72A8 8 0 0 1 232 192a7.9 7.9 0 0 1-3.2-.67L207.38 182a88 88 0 0 1-158.76 0l-21.42 9.33a7.9 7.9 0 0 1-3.2.67a8 8 0 0 1-3.2-15.33L43 167a87.8 87.8 0 0 1-3-23v-8H16a8 8 0 0 1 0-16h24v-8a87.8 87.8 0 0 1 3-23l-22.2-9.67a8 8 0 1 1 6.4-14.66L48.62 74a88 88 0 0 1 158.76 0l21.42-9.36a8 8 0 0 1 6.4 14.66L213 89.05a87.8 87.8 0 0 1 3 23v8h24a8 8 0 0 1 0 16h-24ZM56 120h144v-8a72 72 0 0 0-144 0Zm64 95.54V136H56v8a72.08 72.08 0 0 0 64 71.54M200 144v-8h-64v79.54A72.08 72.08 0 0 0 200 144'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph\\:check{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='m229.66 77.66l-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69L218.34 66.34a8 8 0 0 1 11.32 11.32'/%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph\\:check-circle-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96' opacity='.2'/%3E%3Cpath d='M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph\\:info-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96' opacity='.2'/%3E%3Cpath d='M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph\\:plug-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='m212 132l-58.63 58.63a32 32 0 0 1-45.25 0l-42.75-42.75a32 32 0 0 1 0-45.25L124 44Z' opacity='.2'/%3E%3Cpath d='M237.66 66.34a8 8 0 0 0-11.32 0L192 100.69L155.31 64l34.35-34.34a8 8 0 1 0-11.32-11.32L144 52.69l-26.34-26.35a8 8 0 0 0-11.32 11.32l6.35 6.34l-53 53a40 40 0 0 0 0 56.57l15.71 15.71l-49.06 49.06a8 8 0 0 0 11.32 11.32l49.09-49.09l15.71 15.71a40 40 0 0 0 56.57 0l53-53l6.34 6.35a8 8 0 0 0 11.32-11.32L203.31 112l34.35-34.34a8 8 0 0 0 0-11.32M147.72 185a24 24 0 0 1-33.95 0L71 142.23a24 24 0 0 1 0-33.95l53-53L200.69 132Z'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n.i-ph\\:x-circle-duotone{--un-icon:url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 256 256' width='1.11em' height='1.11em' xmlns='http://www.w3.org/2000/svg' %3E%3Cg fill='currentColor'%3E%3Cpath d='M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96' opacity='.2'/%3E%3Cpath d='M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88'/%3E%3C/g%3E%3C/svg%3E\");-webkit-mask:var(--un-icon) no-repeat;mask:var(--un-icon) no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;background-color:currentColor;color:inherit;width:1.11em;height:1.11em;}\n/* layer: shortcuts */\n.badge{font-size:var(--text-xs-fontSize);line-height:var(--un-leading, var(--text-xs-lineHeight));--un-leading:var(--leading-none);line-height:var(--leading-none);--un-font-weight:var(--fontWeight-medium);font-weight:var(--fontWeight-medium);padding-inline:calc(var(--spacing) * 1.5);padding-block:calc(var(--spacing) * 0.5);border-radius:var(--radius-md);display:inline-flex;gap:calc(var(--spacing) * 1);align-items:center;}\n.color-base{color:color-mix(in srgb, var(--colors-neutral-800) var(--un-text-opacity), transparent);}\n.dark .color-base{color:color-mix(in srgb, var(--colors-neutral-200) var(--un-text-opacity), transparent);}\n.border-base{border-color:color-mix(in oklab, #8882 var(--un-border-opacity), transparent);}\n.hover\\:border-base:hover{border-color:color-mix(in oklab, #8882 var(--un-border-opacity), transparent);}\n.bg-active{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 10%, transparent);}\n.dark .bg-active{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 15%, transparent);}\n.bg-base{background-color:color-mix(in srgb, var(--colors-white) var(--un-bg-opacity), transparent);}\n.dark .bg-base{background-color:color-mix(in oklab, #111 var(--un-bg-opacity), transparent);}\n.bg-glass\\:80{background-color:color-mix(in srgb, var(--colors-white) 80%, transparent);--un-backdrop-blur:blur(7px);-webkit-backdrop-filter:var(--un-backdrop-blur,) var(--un-backdrop-brightness,) var(--un-backdrop-contrast,) var(--un-backdrop-grayscale,) var(--un-backdrop-hue-rotate,) var(--un-backdrop-invert,) var(--un-backdrop-opacity,) var(--un-backdrop-saturate,) var(--un-backdrop-sepia,);backdrop-filter:var(--un-backdrop-blur,) var(--un-backdrop-brightness,) var(--un-backdrop-contrast,) var(--un-backdrop-grayscale,) var(--un-backdrop-hue-rotate,) var(--un-backdrop-invert,) var(--un-backdrop-opacity,) var(--un-backdrop-saturate,) var(--un-backdrop-sepia,);}\n.dark .bg-glass\\:80{background-color:color-mix(in oklab, #111 80%, transparent);}\n.bg-secondary{background-color:color-mix(in oklab, #f6f6f6 var(--un-bg-opacity), transparent);}\n.dark .bg-secondary{background-color:color-mix(in oklab, #101010 var(--un-bg-opacity), transparent);}\n.dark .hover\\:bg-active:hover{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 15%, transparent);}\n.hover\\:bg-active:hover{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 10%, transparent);}\n.dark .op-fade{opacity:55%;}\n.op-fade{opacity:65%;}\n.dark .op-mute{opacity:25%;}\n.op-mute{opacity:30%;}\n.container{width:100%;}\n.z-command-palette{z-index:2147483646;}\n.z-dock-confirm,\n.z-dock-toast{z-index:2147483647;}\n.z-floating-anchor{z-index:2147483644;}\n.z-floating-tooltip{z-index:2147483645;}\n@supports (color: color-mix(in lab, red, red)){\n.color-base{color:color-mix(in oklab, var(--colors-neutral-800) var(--un-text-opacity), transparent);}\n.dark .color-base{color:color-mix(in oklab, var(--colors-neutral-200) var(--un-text-opacity), transparent);}\n.bg-active{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 10%, transparent);}\n.dark .bg-active{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 15%, transparent);}\n.bg-base{background-color:color-mix(in oklab, var(--colors-white) var(--un-bg-opacity), transparent);}\n.bg-glass\\:80{background-color:color-mix(in oklab, var(--colors-white) 80%, transparent);}\n.dark .hover\\:bg-active:hover{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 15%, transparent);}\n.hover\\:bg-active:hover{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 10%, transparent);}\n}\n@media (min-width: 40rem){\n.container{max-width:40rem;}\n}\n@media (min-width: 48rem){\n.container{max-width:48rem;}\n}\n@media (min-width: 64rem){\n.container{max-width:64rem;}\n}\n@media (min-width: 80rem){\n.container{max-width:80rem;}\n}\n@media (min-width: 96rem){\n.container{max-width:96rem;}\n}\n/* layer: default */\n@property --scroll-fade-t{syntax:\"\";inherits:false;initial-value:0px}@property --scroll-fade-b{syntax:\"\";inherits:false;initial-value:0px}@property --scroll-fade-s{syntax:\"\";inherits:false;initial-value:0px}@property --scroll-fade-e{syntax:\"\";inherits:false;initial-value:0px}@property --scroll-fade-mask{syntax:\"*\";inherits:false}\n@property --shimmer-angle{syntax:\"\";inherits:true;initial-value:20deg}@property --shimmer-image{syntax:\"*\";inherits:false}@property --shimmer-text-fill{syntax:\"*\";inherits:false}\n.text-\\[10px\\]{font-size:10px;}\n.text-\\[15px\\]{font-size:15px;}\n.text-0\\.6em{font-size:0.6em;}\n.text-2\\.75{font-size:0.6875rem;}\n.text-2xl{font-size:var(--text-2xl-fontSize);line-height:var(--un-leading, var(--text-2xl-lineHeight));}\n.text-base{font-size:var(--text-base-fontSize);line-height:var(--un-leading, var(--text-base-lineHeight));}\n.text-sm{font-size:var(--text-sm-fontSize);line-height:var(--un-leading, var(--text-sm-lineHeight));}\n.text-xl{font-size:var(--text-xl-fontSize);line-height:var(--un-leading, var(--text-xl-lineHeight));}\n.text-xs{font-size:var(--text-xs-fontSize);line-height:var(--un-leading, var(--text-xs-lineHeight));}\n.dark .dark\\:text-primary-300{color:color-mix(in srgb, var(--colors-primary-300) var(--un-text-opacity), transparent);}\n.dark .dark\\:text-red-400{color:color-mix(in srgb, var(--colors-red-400) var(--un-text-opacity), transparent);}\n.text-amber{color:color-mix(in srgb, var(--colors-amber-DEFAULT) var(--un-text-opacity), transparent);}\n.text-blue{color:color-mix(in srgb, var(--colors-blue-DEFAULT) var(--un-text-opacity), transparent);}\n.text-gray{color:color-mix(in srgb, var(--colors-gray-DEFAULT) var(--un-text-opacity), transparent);}\n.text-green{color:color-mix(in srgb, var(--colors-green-DEFAULT) var(--un-text-opacity), transparent);}\n.text-orange{color:color-mix(in srgb, var(--colors-orange-DEFAULT) var(--un-text-opacity), transparent);}\n.text-primary{color:color-mix(in srgb, var(--colors-primary-DEFAULT) var(--un-text-opacity), transparent);}\n.text-primary-600{color:color-mix(in srgb, var(--colors-primary-600) var(--un-text-opacity), transparent);}\n.text-primary-700{color:color-mix(in srgb, var(--colors-primary-700) var(--un-text-opacity), transparent);}\n.text-red{color:color-mix(in srgb, var(--colors-red-DEFAULT) var(--un-text-opacity), transparent);}\n.text-red-500{color:color-mix(in srgb, var(--colors-red-500) var(--un-text-opacity), transparent);}\n.text-red-600{color:color-mix(in srgb, var(--colors-red-600) var(--un-text-opacity), transparent);}\n.text-white{color:color-mix(in srgb, var(--colors-white) var(--un-text-opacity), transparent);}\n.leading-4{--un-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4);}\n.leading-5{--un-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);}\n.leading-none{--un-leading:var(--leading-none);line-height:var(--leading-none);}\n.leading-relaxed{--un-leading:var(--leading-relaxed);line-height:var(--leading-relaxed);}\n.tracking-tight{--un-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);}\n.tracking-wide{--un-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);}\n.font-bold{--un-font-weight:var(--fontWeight-bold);font-weight:var(--fontWeight-bold);}\n.font-medium{--un-font-weight:var(--fontWeight-medium);font-weight:var(--fontWeight-medium);}\n.font-mono{font-family:var(--font-mono);}\n.font-sans{font-family:var(--font-sans);}\n.tab{-moz-tab-size:4;-o-tab-size:4;tab-size:4;}\n.m-auto,\n.ma{margin:auto;}\n.m1{margin:calc(var(--spacing) * 1);}\n.mx--1{margin-inline:calc(var(--spacing) * -1);}\n.mx--2{margin-inline:calc(var(--spacing) * -2);}\n.mx-auto{margin-inline:auto;}\n.my-2{margin-block:calc(var(--spacing) * 2);}\n.my0\\.5{margin-block:calc(var(--spacing) * 0.5);}\n.my1{margin-block:calc(var(--spacing) * 1);}\n.mb-1\\.5{margin-bottom:calc(var(--spacing) * 1.5);}\n.mb-3{margin-bottom:calc(var(--spacing) * 3);}\n.mb-4{margin-bottom:calc(var(--spacing) * 4);}\n.ml-2{margin-left:calc(var(--spacing) * 2);}\n.ml-6{margin-left:calc(var(--spacing) * 6);}\n.mr-1{margin-right:calc(var(--spacing) * 1);}\n.mt-0\\.5{margin-top:calc(var(--spacing) * 0.5);}\n.mt-1\\.5{margin-top:calc(var(--spacing) * 1.5);}\n.mt-6,\n.mt6{margin-top:calc(var(--spacing) * 6);}\n.mt-auto{margin-top:auto;}\n.mt2{margin-top:calc(var(--spacing) * 2);}\n.mt4{margin-top:calc(var(--spacing) * 4);}\n.p-0\\.5{padding:calc(var(--spacing) * 0.5);}\n.p-1\\.5,\n.p1\\.5{padding:calc(var(--spacing) * 1.5);}\n.p-4{padding:calc(var(--spacing) * 4);}\n.p-5{padding:calc(var(--spacing) * 5);}\n.p1{padding:calc(var(--spacing) * 1);}\n.p2{padding:calc(var(--spacing) * 2);}\n.p6{padding:calc(var(--spacing) * 6);}\n.p8{padding:calc(var(--spacing) * 8);}\n.px,\n.px-4,\n.px4{padding-inline:calc(var(--spacing) * 4);}\n.px-0\\.5{padding-inline:calc(var(--spacing) * 0.5);}\n.px-1,\n.px1{padding-inline:calc(var(--spacing) * 1);}\n.px-1\\.5{padding-inline:calc(var(--spacing) * 1.5);}\n.px-2,\n.px2{padding-inline:calc(var(--spacing) * 2);}\n.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5);}\n.px-3,\n.px3{padding-inline:calc(var(--spacing) * 3);}\n.px-6{padding-inline:calc(var(--spacing) * 6);}\n.py-0{padding-block:calc(var(--spacing) * 0);}\n.py-0\\.5{padding-block:calc(var(--spacing) * 0.5);}\n.py-1,\n.py1{padding-block:calc(var(--spacing) * 1);}\n.py-1\\.5,\n.py1\\.5{padding-block:calc(var(--spacing) * 1.5);}\n.py-2,\n.py2{padding-block:calc(var(--spacing) * 2);}\n.py-2\\.5,\n.py2\\.5{padding-block:calc(var(--spacing) * 2.5);}\n.py-3{padding-block:calc(var(--spacing) * 3);}\n.py-6{padding-block:calc(var(--spacing) * 6);}\n.py-8{padding-block:calc(var(--spacing) * 8);}\n.pb2\\.5{padding-bottom:calc(var(--spacing) * 2.5);}\n.pt-\\[20vh\\]{padding-top:20vh;}\n.pt-6{padding-top:calc(var(--spacing) * 6);}\n.pt2{padding-top:calc(var(--spacing) * 2);}\n.text-center{text-align:center;}\n.text-left{text-align:left;}\n.outline{outline-style:var(--un-outline-style);outline-width:1px;}\n.outline-none{--un-outline-style:none;outline-style:none;}\n.caret-primary-500{caret-color:color-mix(in srgb, var(--colors-primary-500) var(--un-caret-opacity), transparent);}\n.border{border-width:1px;}\n.border-2{border-width:2px;}\n.border-b{border-bottom-width:1px;}\n.border-b-0{border-bottom-width:0px;}\n.border-b-1\\.5{border-bottom-width:1.5px;}\n.border-l{border-left-width:1px;}\n.border-r{border-right-width:1px;}\n.border-r-1\\.5{border-right-width:1.5px;}\n.border-t{border-top-width:1px;}\n.border-t-0{border-top-width:0px;}\n.last\\:border-b-0:last-child{border-bottom-width:0px;}\n.border-amber\\/20{border-color:color-mix(in srgb, var(--colors-amber-DEFAULT) 20%, transparent);}\n.border-current{border-color:currentColor;}\n.border-primary-400\\/60{border-color:color-mix(in srgb, var(--colors-primary-400) 60%, transparent);}\n.border-primary\\/30{border-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 30%, transparent);}\n.border-red-500\\/70{border-color:color-mix(in srgb, var(--colors-red-500) 70%, transparent);}\n.border-transparent{border-color:transparent;}\n.dark .dark\\:border-primary-400\\/50{border-color:color-mix(in srgb, var(--colors-primary-400) 50%, transparent);}\n.focus-within\\:border-gray\\/15:focus-within{border-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 15%, transparent);}\n.hover\\:border-gray\\/10:hover{border-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 10%, transparent);}\n.focus\\:border-primary-500:focus{border-color:color-mix(in srgb, var(--colors-primary-500) var(--un-border-opacity), transparent);}\n.focus\\:border-primary\\/40:focus{border-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent);}\n.border-t-transparent{border-top-color:transparent;}\n.rounded{border-radius:var(--radius-DEFAULT);}\n.rounded-full{border-radius:calc(infinity * 1px);}\n.rounded-lg{border-radius:var(--radius-lg);}\n.rounded-md{border-radius:var(--radius-md);}\n.rounded-xl{border-radius:var(--radius-xl);}\n.rounded-r{border-top-right-radius:var(--radius-DEFAULT);border-bottom-right-radius:var(--radius-DEFAULT);}\n.rounded-t-md{border-top-left-radius:var(--radius-md);border-top-right-radius:var(--radius-md);}\n.rounded-tr-md{border-top-right-radius:var(--radius-md);}\n.bg-\\[\\#8881\\]{background-color:color-mix(in oklab, #8881 var(--un-bg-opacity), transparent);}\n.bg-amber{background-color:color-mix(in srgb, var(--colors-amber-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-amber\\/10{background-color:color-mix(in srgb, var(--colors-amber-DEFAULT) 10%, transparent);}\n.bg-black\\/30,\n.dark .dark\\:bg-black\\/30{background-color:color-mix(in srgb, var(--colors-black) 30%, transparent);}\n.bg-blue{background-color:color-mix(in srgb, var(--colors-blue-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-blue\\/10{background-color:color-mix(in srgb, var(--colors-blue-DEFAULT) 10%, transparent);}\n.bg-gray{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-gray\\/10{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 10%, transparent);}\n.bg-gray\\/20{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 20%, transparent);}\n.bg-gray\\/3{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 3%, transparent);}\n.bg-gray\\/30{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 30%, transparent);}\n.bg-gray\\/5{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 5%, transparent);}\n.bg-green{background-color:color-mix(in srgb, var(--colors-green-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-orange\\/10{background-color:color-mix(in srgb, var(--colors-orange-DEFAULT) 10%, transparent);}\n.bg-primary{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-primary-500\\/20{background-color:color-mix(in srgb, var(--colors-primary-500) 20%, transparent);}\n.bg-primary-600{background-color:color-mix(in srgb, var(--colors-primary-600) var(--un-bg-opacity), transparent);}\n.bg-primary\\/10{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 10%, transparent);}\n.bg-primary\\/15{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 15%, transparent);}\n.bg-primary\\/20{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 20%, transparent);}\n.bg-red{background-color:color-mix(in srgb, var(--colors-red-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-red-500\\/12{background-color:color-mix(in srgb, var(--colors-red-500) 12%, transparent);}\n.bg-red\\/10{background-color:color-mix(in srgb, var(--colors-red-DEFAULT) 10%, transparent);}\n.bg-transparent{background-color:transparent;}\n.bg-white{background-color:color-mix(in srgb, var(--colors-white) var(--un-bg-opacity), transparent);}\n.bg-white\\/50{background-color:color-mix(in srgb, var(--colors-white) 50%, transparent);}\n.dark .dark\\:bg-black\\/45{background-color:color-mix(in srgb, var(--colors-black) 45%, transparent);}\n.hover\\:bg-\\[\\#8881\\]:hover{background-color:color-mix(in oklab, #8881 var(--un-bg-opacity), transparent);}\n.hover\\:bg-\\[\\#8882\\]:hover{background-color:color-mix(in oklab, #8882 var(--un-bg-opacity), transparent);}\n.hover\\:bg-\\[\\#8883\\]:hover{background-color:color-mix(in oklab, #8883 var(--un-bg-opacity), transparent);}\n.hover\\:bg-gray\\/10:hover{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 10%, transparent);}\n.hover\\:bg-gray\\/15:hover{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 15%, transparent);}\n.hover\\:bg-gray\\/20:hover{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 20%, transparent);}\n.hover\\:bg-gray\\/5:hover{background-color:color-mix(in srgb, var(--colors-gray-DEFAULT) 5%, transparent);}\n.hover\\:bg-orange\\/20:hover{background-color:color-mix(in srgb, var(--colors-orange-DEFAULT) 20%, transparent);}\n.hover\\:bg-primary-700:hover{background-color:color-mix(in srgb, var(--colors-primary-700) var(--un-bg-opacity), transparent);}\n.hover\\:bg-primary\\/20:hover{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 20%, transparent);}\n.hover\\:bg-primary\\/25:hover{background-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 25%, transparent);}\n.hover\\:bg-red-500\\/20:hover{background-color:color-mix(in srgb, var(--colors-red-500) 20%, transparent);}\n.hover\\:bg-red\\/20:hover{background-color:color-mix(in srgb, var(--colors-red-DEFAULT) 20%, transparent);}\n.op0,\n.opacity-0{opacity:0%;}\n.op100,\n.opacity-100,\n.group:hover .group-hover\\:opacity-100{opacity:100%;}\n.op25{opacity:25%;}\n.op30{opacity:30%;}\n.op40{opacity:40%;}\n.op50,\n.group:hover .group-hover\\:op50{opacity:50%;}\n.op60{opacity:60%;}\n.op60\\!{opacity:60% !important;}\n.op70{opacity:70%;}\n.op75{opacity:75%;}\n.op80{opacity:80%;}\n.hover\\:op100:hover{opacity:100%;}\n.hover\\:op60:hover{opacity:60%;}\n.hover\\:op70:hover{opacity:70%;}\n.hover\\:op80:hover{opacity:80%;}\n.disabled\\:op40:disabled{opacity:40%;}\n.disabled\\:op50:disabled{opacity:50%;}\n.line-through{text-decoration-line:line-through;}\n.flex{display:flex;}\n.inline-flex{display:inline-flex;}\n.flex-1{flex:1 1 0%;}\n.flex-auto{flex:1 1 auto;}\n.flex-none{flex:none;}\n.shrink-0{flex-shrink:0;}\n.flex-row{flex-direction:row;}\n.flex-row-reverse{flex-direction:row-reverse;}\n.flex-col{flex-direction:column;}\n.flex-col-reverse{flex-direction:column-reverse;}\n.flex-wrap{flex-wrap:wrap;}\n.gap-0{gap:calc(var(--spacing) * 0);}\n.gap-0\\.5{gap:calc(var(--spacing) * 0.5);}\n.gap-1{gap:calc(var(--spacing) * 1);}\n.gap-1\\.5{gap:calc(var(--spacing) * 1.5);}\n.gap-2{gap:calc(var(--spacing) * 2);}\n.gap-3{gap:calc(var(--spacing) * 3);}\n.gap-4{gap:calc(var(--spacing) * 4);}\n.gap-6{gap:calc(var(--spacing) * 6);}\n.gap-x-2{column-gap:calc(var(--spacing) * 2);}\n.gap-y-0\\.5{row-gap:calc(var(--spacing) * 0.5);}\n.grid{display:grid;}\n.cols-\\[max-content_1fr\\]{grid-template-columns:max-content 1fr;}\n.h-0\\.5{height:calc(var(--spacing) * 0.5);}\n.h-1\\.5{height:calc(var(--spacing) * 1.5);}\n.h-10{height:calc(var(--spacing) * 10);}\n.h-14{height:calc(var(--spacing) * 14);}\n.h-16{height:calc(var(--spacing) * 16);}\n.h-20px{height:20px;}\n.h-24{height:calc(var(--spacing) * 24);}\n.h-3{height:calc(var(--spacing) * 3);}\n.h-3\\.5{height:calc(var(--spacing) * 3.5);}\n.h-4{height:calc(var(--spacing) * 4);}\n.h-4\\.5{height:calc(var(--spacing) * 4.5);}\n.h-5{height:calc(var(--spacing) * 5);}\n.h-6{height:calc(var(--spacing) * 6);}\n.h-7{height:calc(var(--spacing) * 7);}\n.h-8{height:calc(var(--spacing) * 8);}\n.h-full{height:100%;}\n.h-px{height:1px;}\n.h-screen{height:100vh;}\n.max-h-\\[60vh\\]{max-height:60vh;}\n.max-w-108{max-width:calc(var(--spacing) * 108);}\n.max-w-200{max-width:calc(var(--spacing) * 200);}\n.max-w-220px{max-width:220px;}\n.max-w-64{max-width:calc(var(--spacing) * 64);}\n.max-w-92{max-width:calc(var(--spacing) * 92);}\n.max-w-96{max-width:calc(var(--spacing) * 96);}\n.max-w-full{max-width:100%;}\n.min-h-0{min-height:calc(var(--spacing) * 0);}\n.min-h-16{min-height:calc(var(--spacing) * 16);}\n.min-h-5{min-height:calc(var(--spacing) * 5);}\n.min-w-0{min-width:calc(var(--spacing) * 0);}\n.min-w-28{min-width:calc(var(--spacing) * 28);}\n.min-w-36{min-width:calc(var(--spacing) * 36);}\n.min-w-40{min-width:calc(var(--spacing) * 40);}\n.min-w-44{min-width:calc(var(--spacing) * 44);}\n.w-\\[40px\\]{width:40px;}\n.w-1\\.5{width:calc(var(--spacing) * 1.5);}\n.w-10{width:calc(var(--spacing) * 10);}\n.w-11{width:calc(var(--spacing) * 11);}\n.w-12{width:calc(var(--spacing) * 12);}\n.w-16{width:calc(var(--spacing) * 16);}\n.w-2\\.5{width:calc(var(--spacing) * 2.5);}\n.w-20px{width:20px;}\n.w-24{width:calc(var(--spacing) * 24);}\n.w-2px{width:2px;}\n.w-3{width:calc(var(--spacing) * 3);}\n.w-3\\.5{width:calc(var(--spacing) * 3.5);}\n.w-4{width:calc(var(--spacing) * 4);}\n.w-4\\.5{width:calc(var(--spacing) * 4.5);}\n.w-5{width:calc(var(--spacing) * 5);}\n.w-6{width:calc(var(--spacing) * 6);}\n.w-64{width:calc(var(--spacing) * 64);}\n.w-7{width:calc(var(--spacing) * 7);}\n.w-72{width:calc(var(--spacing) * 72);}\n.w-8{width:calc(var(--spacing) * 8);}\n.w-96{width:calc(var(--spacing) * 96);}\n.w-fit{width:fit-content;}\n.w-full{width:100%;}\n.w-lg{width:var(--container-lg);}\n.w-max{width:max-content;}\n.w-px{width:1px;}\n.w-screen{width:100vw;}\n.inline{display:inline;}\n.block{display:block;}\n.contents{display:contents;}\n.dark .dark-hidden,\n.hidden,\n.light .light-hidden{display:none;}\n.visible{visibility:visible;}\n.invisible{visibility:hidden;}\n.collapse{visibility:collapse;}\n.cursor-default{cursor:default;}\n.cursor-pointer{cursor:pointer;}\n.cursor-not-allowed{cursor:not-allowed;}\n.cursor-grab{cursor:grab;}\n.pointer-events-auto{pointer-events:auto;}\n.pointer-events-none{pointer-events:none;}\n.disabled\\:pointer-events-none:disabled{pointer-events:none;}\n.resize{resize:both;}\n.select-none{-webkit-user-select:none;user-select:none;}\n.whitespace-pre-wrap{white-space:pre-wrap;}\n.ws-nowrap{white-space:nowrap;}\n.text-balance{text-wrap:balance;}\n.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.uppercase{text-transform:uppercase;}\n.capitalize{text-transform:capitalize;}\n.italic{font-style:italic;}\n.ring-1\\.5{--un-ring-shadow:var(--un-ring-inset,) 0 0 0 calc(1.5px + var(--un-ring-offset-width)) var(--un-ring-color, currentColor);box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}\n.focus\\:ring-1:focus{--un-ring-shadow:var(--un-ring-inset,) 0 0 0 calc(1px + var(--un-ring-offset-width)) var(--un-ring-color, currentColor);box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}\n.focus\\:ring-3:focus{--un-ring-shadow:var(--un-ring-inset,) 0 0 0 calc(3px + var(--un-ring-offset-width)) var(--un-ring-color, currentColor);box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}\n.focus-visible\\:ring-3:focus-visible{--un-ring-shadow:var(--un-ring-inset,) 0 0 0 calc(3px + var(--un-ring-offset-width)) var(--un-ring-color, currentColor);box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}\n.ring-purple\\/50{--un-ring-color:color-mix(in srgb, var(--colors-purple-DEFAULT) 50%, transparent);}\n.focus\\:ring-primary-500\\/25:focus{--un-ring-color:color-mix(in srgb, var(--colors-primary-500) 25%, transparent);}\n.focus\\:ring-primary\\/30:focus{--un-ring-color:color-mix(in srgb, var(--colors-primary-DEFAULT) 30%, transparent);}\n.focus-visible\\:ring-primary-500\\/30:focus-visible{--un-ring-color:color-mix(in srgb, var(--colors-primary-500) 30%, transparent);}\n.focus-visible\\:ring-primary-500\\/40:focus-visible{--un-ring-color:color-mix(in srgb, var(--colors-primary-500) 40%, transparent);}\n.focus-visible\\:ring-red-500\\/30:focus-visible{--un-ring-color:color-mix(in srgb, var(--colors-red-500) 30%, transparent);}\n.shadow,\n.shadow-sm{--un-shadow:0 1px 3px 0 var(--un-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}\n.shadow-xl{--un-shadow:0 20px 25px -5px var(--un-shadow-color, rgb(0 0 0 / 0.1)),0 8px 10px -6px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}\n.-translate-y-2{--un-translate-y:calc(var(--spacing) * -2);translate:var(--un-translate-x) var(--un-translate-y);}\n.translate-x--1\\/2{--un-translate-x:-50%;translate:var(--un-translate-x) var(--un-translate-y);}\n.translate-x-1{--un-translate-x:calc(var(--spacing) * 1);translate:var(--un-translate-x) var(--un-translate-y);}\n.translate-x-4{--un-translate-x:calc(var(--spacing) * 4);translate:var(--un-translate-x) var(--un-translate-y);}\n.translate-x-5{--un-translate-x:calc(var(--spacing) * 5);translate:var(--un-translate-x) var(--un-translate-y);}\n.translate-y--1\\/2{--un-translate-y:-50%;translate:var(--un-translate-x) var(--un-translate-y);}\n.translate-y-0{--un-translate-y:calc(var(--spacing) * 0);translate:var(--un-translate-x) var(--un-translate-y);}\n.rotate--45{rotate:-45deg;}\n.rotate-0{rotate:0deg;}\n.rotate-180{rotate:180deg;}\n.rotate-270{rotate:270deg;}\n.rotate-90{rotate:90deg;}\n.scale-100{--un-scale-x:100%;--un-scale-y:100%;scale:var(--un-scale-x) var(--un-scale-y);}\n.scale-120{--un-scale-x:120%;--un-scale-y:120%;scale:var(--un-scale-x) var(--un-scale-y);}\n.scale-98{--un-scale-x:98%;--un-scale-y:98%;scale:var(--un-scale-x) var(--un-scale-y);}\n.hover\\:scale-110:hover{--un-scale-x:110%;--un-scale-y:110%;scale:var(--un-scale-x) var(--un-scale-y);}\n.scale-y--100{--un-scale-y:-100%;scale:var(--un-scale-x) var(--un-scale-y);}\n.transform{transform:var(--un-rotate-x) var(--un-rotate-y) var(--un-rotate-z) var(--un-skew-x) var(--un-skew-y);}\n.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,--un-gradient-from,--un-gradient-via,--un-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));}\n.transition-all{transition-property:all;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));}\n.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,--un-gradient-from,--un-gradient-via,--un-gradient-to;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));}\n.transition-opacity{transition-property:opacity;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));}\n.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));}\n.duration-150{--un-duration:150ms;transition-duration:150ms;}\n.duration-200{--un-duration:200ms;transition-duration:200ms;}\n.duration-300{--un-duration:300ms;transition-duration:300ms;}\n.delay-200{transition-delay:200ms;}\n.ease-in{--un-ease:var(--ease-in);transition-timing-function:var(--ease-in);}\n.ease-out{--un-ease:var(--ease-out);transition-timing-function:var(--ease-out);}\n.items-start{align-items:flex-start;}\n.items-center{align-items:center;}\n.box-border{box-sizing:border-box;}\n.inset-0{inset:calc(var(--spacing) * 0);}\n.bottom-0{bottom:calc(var(--spacing) * 0);}\n.bottom-4{bottom:calc(var(--spacing) * 4);}\n.bottom-4px{bottom:4px;}\n.left--1{left:calc(var(--spacing) * -1);}\n.left-0{left:calc(var(--spacing) * 0);}\n.left-1\\/2{left:50%;}\n.left-2{left:calc(var(--spacing) * 2);}\n.left-5px{left:5px;}\n.right--1{right:calc(var(--spacing) * -1);}\n.right--1px{right:-1px;}\n.right-0{right:calc(var(--spacing) * 0);}\n.right-0\\.5{right:calc(var(--spacing) * 0.5);}\n.right-2{right:calc(var(--spacing) * 2);}\n.right-4{right:calc(var(--spacing) * 4);}\n.top--32px{top:-32px;}\n.top-0\\.5{top:calc(var(--spacing) * 0.5);}\n.top-1{top:calc(var(--spacing) * 1);}\n.top-1\\/2{top:50%;}\n.top-4px{top:4px;}\n.justify-end{justify-content:flex-end;}\n.justify-center{justify-content:center;}\n.justify-between{justify-content:space-between;}\n.absolute{position:absolute;}\n.fixed{position:fixed;}\n.relative{position:relative;}\n.static{position:static;}\n.z--1{z-index:-1;}\n.of-hidden,\n.overflow-hidden{overflow:hidden;}\n.overflow-auto{overflow:auto;}\n.of-x-hidden{overflow-x:hidden;}\n.of-y-auto{overflow-y:auto;}\n.of-y-hidden{overflow-y:hidden;}\n.dark .dark\\:fill-white{fill:color-mix(in srgb, var(--colors-white) var(--un-fill-opacity), transparent);}\n.fill-black{fill:color-mix(in srgb, var(--colors-black) var(--un-fill-opacity), transparent);}\n@keyframes pulse{0%, 100% {opacity:1} 50% {opacity:.5}}\n@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}\n.animate-pulse{animation:pulse 2s cubic-bezier(0.4,0,.6,1) infinite;}\n.animate-spin{animation:spin 1s linear infinite;}\n.backdrop-blur-0{--un-backdrop-blur:blur(0px);-webkit-backdrop-filter:var(--un-backdrop-blur,) var(--un-backdrop-brightness,) var(--un-backdrop-contrast,) var(--un-backdrop-grayscale,) var(--un-backdrop-hue-rotate,) var(--un-backdrop-invert,) var(--un-backdrop-opacity,) var(--un-backdrop-saturate,) var(--un-backdrop-sepia,);backdrop-filter:var(--un-backdrop-blur,) var(--un-backdrop-brightness,) var(--un-backdrop-contrast,) var(--un-backdrop-grayscale,) var(--un-backdrop-hue-rotate,) var(--un-backdrop-invert,) var(--un-backdrop-opacity,) var(--un-backdrop-saturate,) var(--un-backdrop-sepia,);}\n.backdrop-blur-1{--un-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--un-backdrop-blur,) var(--un-backdrop-brightness,) var(--un-backdrop-contrast,) var(--un-backdrop-grayscale,) var(--un-backdrop-hue-rotate,) var(--un-backdrop-invert,) var(--un-backdrop-opacity,) var(--un-backdrop-saturate,) var(--un-backdrop-sepia,);backdrop-filter:var(--un-backdrop-blur,) var(--un-backdrop-brightness,) var(--un-backdrop-contrast,) var(--un-backdrop-grayscale,) var(--un-backdrop-hue-rotate,) var(--un-backdrop-invert,) var(--un-backdrop-opacity,) var(--un-backdrop-saturate,) var(--un-backdrop-sepia,);}\n.blur-2xl{--un-blur:blur(40px);filter:var(--un-blur,) var(--un-brightness,) var(--un-contrast,) var(--un-grayscale,) var(--un-hue-rotate,) var(--un-invert,) var(--un-saturate,) var(--un-sepia,) var(--un-drop-shadow,);}\n.saturate-0{--un-saturate:saturate(0%);filter:var(--un-blur,) var(--un-brightness,) var(--un-contrast,) var(--un-grayscale,) var(--un-hue-rotate,) var(--un-invert,) var(--un-saturate,) var(--un-sepia,) var(--un-drop-shadow,);}\n.table{display:table;}\n.tabular-nums{--un-numeric-spacing:tabular-nums;font-variant-numeric:var(--un-ordinal,) var(--un-slashed-zero,) var(--un-numeric-figure,) var(--un-numeric-spacing,) var(--un-numeric-fraction,);}\n.space-y-0\\.5{\n:where(&>:not(:last-child)){--un-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 0.5) * var(--un-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0.5) * calc(1 - var(--un-space-y-reverse)));}\n}\n@supports (color: color-mix(in lab, red, red)){\n.dark .dark\\:text-primary-300{color:color-mix(in oklab, var(--colors-primary-300) var(--un-text-opacity), transparent);}\n.dark .dark\\:text-red-400{color:color-mix(in oklab, var(--colors-red-400) var(--un-text-opacity), transparent);}\n.text-amber{color:color-mix(in oklab, var(--colors-amber-DEFAULT) var(--un-text-opacity), transparent);}\n.text-blue{color:color-mix(in oklab, var(--colors-blue-DEFAULT) var(--un-text-opacity), transparent);}\n.text-gray{color:color-mix(in oklab, var(--colors-gray-DEFAULT) var(--un-text-opacity), transparent);}\n.text-green{color:color-mix(in oklab, var(--colors-green-DEFAULT) var(--un-text-opacity), transparent);}\n.text-orange{color:color-mix(in oklab, var(--colors-orange-DEFAULT) var(--un-text-opacity), transparent);}\n.text-primary{color:color-mix(in oklab, var(--colors-primary-DEFAULT) var(--un-text-opacity), transparent);}\n.text-primary-600{color:color-mix(in oklab, var(--colors-primary-600) var(--un-text-opacity), transparent);}\n.text-primary-700{color:color-mix(in oklab, var(--colors-primary-700) var(--un-text-opacity), transparent);}\n.text-red{color:color-mix(in oklab, var(--colors-red-DEFAULT) var(--un-text-opacity), transparent);}\n.text-red-500{color:color-mix(in oklab, var(--colors-red-500) var(--un-text-opacity), transparent);}\n.text-red-600{color:color-mix(in oklab, var(--colors-red-600) var(--un-text-opacity), transparent);}\n.text-white{color:color-mix(in oklab, var(--colors-white) var(--un-text-opacity), transparent);}\n.caret-primary-500{caret-color:color-mix(in oklab, var(--colors-primary-500) var(--un-caret-opacity), transparent);}\n.border-amber\\/20{border-color:color-mix(in oklab, var(--colors-amber-DEFAULT) 20%, transparent);}\n.border-primary-400\\/60{border-color:color-mix(in oklab, var(--colors-primary-400) 60%, transparent);}\n.border-primary\\/30{border-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 30%, transparent);}\n.border-red-500\\/70{border-color:color-mix(in oklab, var(--colors-red-500) 70%, transparent);}\n.dark .dark\\:border-primary-400\\/50{border-color:color-mix(in oklab, var(--colors-primary-400) 50%, transparent);}\n.focus-within\\:border-gray\\/15:focus-within{border-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 15%, transparent);}\n.hover\\:border-gray\\/10:hover{border-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 10%, transparent);}\n.focus\\:border-primary-500:focus{border-color:color-mix(in oklab, var(--colors-primary-500) var(--un-border-opacity), transparent);}\n.focus\\:border-primary\\/40:focus{border-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 40%, transparent);}\n.bg-amber{background-color:color-mix(in oklab, var(--colors-amber-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-amber\\/10{background-color:color-mix(in oklab, var(--colors-amber-DEFAULT) 10%, transparent);}\n.bg-black\\/30{background-color:color-mix(in oklab, var(--colors-black) 30%, transparent);}\n.bg-blue{background-color:color-mix(in oklab, var(--colors-blue-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-blue\\/10{background-color:color-mix(in oklab, var(--colors-blue-DEFAULT) 10%, transparent);}\n.bg-gray{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-gray\\/10{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 10%, transparent);}\n.bg-gray\\/20{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 20%, transparent);}\n.bg-gray\\/3{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 3%, transparent);}\n.bg-gray\\/30{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 30%, transparent);}\n.bg-gray\\/5{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 5%, transparent);}\n.bg-green{background-color:color-mix(in oklab, var(--colors-green-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-orange\\/10{background-color:color-mix(in oklab, var(--colors-orange-DEFAULT) 10%, transparent);}\n.bg-primary{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-primary-500\\/20{background-color:color-mix(in oklab, var(--colors-primary-500) 20%, transparent);}\n.bg-primary-600{background-color:color-mix(in oklab, var(--colors-primary-600) var(--un-bg-opacity), transparent);}\n.bg-primary\\/10{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 10%, transparent);}\n.bg-primary\\/15{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 15%, transparent);}\n.bg-primary\\/20{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 20%, transparent);}\n.bg-red{background-color:color-mix(in oklab, var(--colors-red-DEFAULT) var(--un-bg-opacity), transparent);}\n.bg-red-500\\/12{background-color:color-mix(in oklab, var(--colors-red-500) 12%, transparent);}\n.bg-red\\/10{background-color:color-mix(in oklab, var(--colors-red-DEFAULT) 10%, transparent);}\n.bg-white{background-color:color-mix(in oklab, var(--colors-white) var(--un-bg-opacity), transparent);}\n.bg-white\\/50{background-color:color-mix(in oklab, var(--colors-white) 50%, transparent);}\n.dark .dark\\:bg-black\\/30{background-color:color-mix(in oklab, var(--colors-black) 30%, transparent);}\n.dark .dark\\:bg-black\\/45{background-color:color-mix(in oklab, var(--colors-black) 45%, transparent);}\n.hover\\:bg-gray\\/10:hover{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 10%, transparent);}\n.hover\\:bg-gray\\/15:hover{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 15%, transparent);}\n.hover\\:bg-gray\\/20:hover{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 20%, transparent);}\n.hover\\:bg-gray\\/5:hover{background-color:color-mix(in oklab, var(--colors-gray-DEFAULT) 5%, transparent);}\n.hover\\:bg-orange\\/20:hover{background-color:color-mix(in oklab, var(--colors-orange-DEFAULT) 20%, transparent);}\n.hover\\:bg-primary-700:hover{background-color:color-mix(in oklab, var(--colors-primary-700) var(--un-bg-opacity), transparent);}\n.hover\\:bg-primary\\/20:hover{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 20%, transparent);}\n.hover\\:bg-primary\\/25:hover{background-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 25%, transparent);}\n.hover\\:bg-red-500\\/20:hover{background-color:color-mix(in oklab, var(--colors-red-500) 20%, transparent);}\n.hover\\:bg-red\\/20:hover{background-color:color-mix(in oklab, var(--colors-red-DEFAULT) 20%, transparent);}\n.ring-purple\\/50{--un-ring-color:color-mix(in oklab, var(--colors-purple-DEFAULT) 50%, transparent);}\n.focus\\:ring-primary-500\\/25:focus{--un-ring-color:color-mix(in oklab, var(--colors-primary-500) 25%, transparent);}\n.focus\\:ring-primary\\/30:focus{--un-ring-color:color-mix(in oklab, var(--colors-primary-DEFAULT) 30%, transparent);}\n.focus-visible\\:ring-primary-500\\/30:focus-visible{--un-ring-color:color-mix(in oklab, var(--colors-primary-500) 30%, transparent);}\n.focus-visible\\:ring-primary-500\\/40:focus-visible{--un-ring-color:color-mix(in oklab, var(--colors-primary-500) 40%, transparent);}\n.focus-visible\\:ring-red-500\\/30:focus-visible{--un-ring-color:color-mix(in oklab, var(--colors-red-500) 30%, transparent);}\n.dark .dark\\:fill-white{fill:color-mix(in oklab, var(--colors-white) var(--un-fill-opacity), transparent);}\n.fill-black{fill:color-mix(in oklab, var(--colors-black) var(--un-fill-opacity), transparent);}\n}\n@media (min-width: 40rem){\n.sm\\:gap-2\\.5{gap:calc(var(--spacing) * 2.5);}\n}" diff --git a/packages/hub-ui/src/client/components/DockEmbedded.ts b/packages/hub-ui/src/client/components/DockEmbedded.ts new file mode 100644 index 00000000..a00f0a6c --- /dev/null +++ b/packages/hub-ui/src/client/components/DockEmbedded.ts @@ -0,0 +1,17 @@ +import type { DocksContext } from '@devframes/hub/client' +import type { VueElementConstructor } from 'vue' +import { defineCustomElement } from 'vue' +import css from '../.generated/css' +import Component from './dock/DockEmbedded.vue' + +export const DockEmbedded = defineCustomElement( + Component, + { + shadowRoot: true, + styles: [css], + }, +) as VueElementConstructor<{ + context: DocksContext +}> + +customElements.define('devframes-dock-embedded', DockEmbedded) diff --git a/packages/hub-ui/src/client/components/DockStandalone.ts b/packages/hub-ui/src/client/components/DockStandalone.ts new file mode 100644 index 00000000..6ba040c5 --- /dev/null +++ b/packages/hub-ui/src/client/components/DockStandalone.ts @@ -0,0 +1,18 @@ +import type { DocksContext } from '@devframes/hub/client' +import type { VueElementConstructor } from 'vue' +import { defineCustomElement } from 'vue' +import css from '../.generated/css' +import Component from './dock/DockStandalone.vue' + +export const DockStandalone = defineCustomElement( + Component, + { + shadowRoot: true, + styles: [css], + }, +) as VueElementConstructor<{ + context: DocksContext +}> + +if (!customElements.get('devframes-dock-standalone')) + customElements.define('devframes-dock-standalone', DockStandalone) diff --git a/packages/hub-ui/src/client/components/command-palette/CommandPalette.stories.ts b/packages/hub-ui/src/client/components/command-palette/CommandPalette.stories.ts new file mode 100644 index 00000000..b6709144 --- /dev/null +++ b/packages/hub-ui/src/client/components/command-palette/CommandPalette.stories.ts @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { defineComponent, h, onMounted } from 'vue' +import { groupedEntries } from '../../stories/fixtures' +import { mountWithContext } from '../../stories/story-helpers' +import CommandPalette from './CommandPalette.vue' + +const meta = { + title: 'Commands/Palette', + component: CommandPalette, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + // Fixed, full-screen overlay — render in an iframe for the docs canvas. + docs: { + story: { inline: false, height: '480px' }, + description: { + component: 'The ⌘K command palette. Lists client + server commands (and dock navigation), supports fuzzy search and drill-down into grouped commands.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** + * The palette opened over the built-in and dock-navigation commands. + * + * `paletteOpen` is flipped in `onMounted` (not before mount): the palette's + * open transition keys off the `show` change, so a value that's already `true` + * at mount would leave it stuck at `opacity-0`. + */ +export const Open: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries }, + ctx => h(defineComponent({ + setup() { + onMounted(() => { + ctx.commands.paletteOpen = true + }) + return () => h(CommandPalette, { context: ctx }) + }, + })), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/command-palette/CommandPalette.vue b/packages/hub-ui/src/client/components/command-palette/CommandPalette.vue new file mode 100644 index 00000000..1aba472c --- /dev/null +++ b/packages/hub-ui/src/client/components/command-palette/CommandPalette.vue @@ -0,0 +1,338 @@ + + + diff --git a/packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.stories.ts b/packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.stories.ts new file mode 100644 index 00000000..4a1ee039 --- /dev/null +++ b/packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.stories.ts @@ -0,0 +1,46 @@ +import type { DevframeCommandEntry } from '@devframes/hub' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import CommandPaletteItem from './CommandPaletteItem.vue' + +const entry: DevframeCommandEntry = { + id: 'devframes:open-settings', + source: 'client', + title: 'Open Settings', + icon: 'ph:gear-duotone', +} as DevframeCommandEntry + +const meta = { + title: 'Commands/PaletteItem', + component: CommandPaletteItem, + tags: ['autodocs'], + decorators: [() => ({ template: '
' })], + args: { + entry, + showParentTitle: false, + selected: false, + loading: false, + keybindings: [], + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** A resting command row. */ +export const Default: Story = {} + +/** The highlighted (keyboard-focused) row. */ +export const Selected: Story = { args: { selected: true } } + +/** With a keybinding badge on the right. */ +export const WithKeybinding: Story = { + args: { keybindings: [{ key: 'Mod+,' }] }, +} + +/** A child command showing its parent group as a prefix. */ +export const WithParentTitle: Story = { + args: { parentTitle: 'Docks', showParentTitle: true }, +} + +/** Mid-execution: the row shows a spinner. */ +export const Loading: Story = { args: { loading: true, selected: true } } diff --git a/packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.vue b/packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.vue new file mode 100644 index 00000000..40c1e253 --- /dev/null +++ b/packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.vue @@ -0,0 +1,68 @@ + + + diff --git a/packages/hub-ui/src/client/components/command-palette/KeybindingBadge.stories.ts b/packages/hub-ui/src/client/components/command-palette/KeybindingBadge.stories.ts new file mode 100644 index 00000000..9ff1c8e6 --- /dev/null +++ b/packages/hub-ui/src/client/components/command-palette/KeybindingBadge.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import KeybindingBadge from './KeybindingBadge.vue' + +const meta = { + title: 'Commands/KeybindingBadge', + component: KeybindingBadge, + tags: ['autodocs'], + decorators: [() => ({ template: '
' })], + args: { keyString: 'Mod+K' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** A single chord, formatted for the current platform (⌘ on macOS, Ctrl elsewhere). */ +export const Default: Story = {} + +/** A shortcut with a modifier and shift. */ +export const WithShift: Story = { args: { keyString: 'Mod+Shift+P' } } + +/** A plain key. */ +export const SingleKey: Story = { args: { keyString: 'Escape' } } + +/** Several badges together, as the palette lists them. */ +export const Gallery: Story = { + render: () => ({ + setup: () => () => h('div', { class: 'flex flex-col gap-2 items-start p6 font-sans' }, [ + h(KeybindingBadge, { keyString: 'Mod+K' }), + h(KeybindingBadge, { keyString: 'Mod+Shift+P' }), + h(KeybindingBadge, { keyString: 'Escape' }), + h(KeybindingBadge, { keyString: 'Alt+ArrowUp' }), + ]), + }), +} diff --git a/packages/hub-ui/src/client/components/command-palette/KeybindingBadge.vue b/packages/hub-ui/src/client/components/command-palette/KeybindingBadge.vue new file mode 100644 index 00000000..aef6f480 --- /dev/null +++ b/packages/hub-ui/src/client/components/command-palette/KeybindingBadge.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/hub-ui/src/client/components/display/Button.stories.ts b/packages/hub-ui/src/client/components/display/Button.stories.ts new file mode 100644 index 00000000..96480480 --- /dev/null +++ b/packages/hub-ui/src/client/components/display/Button.stories.ts @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import Button from './Button.vue' + +const variants = ['primary', 'soft', 'secondary', 'ghost', 'danger'] as const +const sizes = ['sm', 'md', 'lg'] as const + +function row(children: any, label?: string) { + return h('div', { class: 'flex items-center gap-3 flex-wrap' }, [ + label ? h('div', { class: 'w-20 text-xs op-mute font-mono' }, label) : null, + children, + ]) +} + +const meta = { + title: 'Display/Button', + component: Button, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'The unified Devframes button, shared by the confirm modal, the auth OTP form, the launcher view and json-render. Variants (`primary`, `soft`, `secondary`, `ghost`, `danger`), sizes (`sm`/`md`/`lg`), plus `block`, `loading`, `disabled` and an optional leading `#icon` slot.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Every variant at the default size. */ +export const Variants: Story = { + render: () => ({ + setup: () => () => h('div', { class: 'flex flex-col gap-3 p8 bg-base color-base font-sans' }, variants.map(variant => row( + h(Button, { variant }, { default: () => variant[0]!.toUpperCase() + variant.slice(1) }), + variant, + ))), + }), +} + +/** The three sizes (shown with the primary variant). */ +export const Sizes: Story = { + render: () => ({ + setup: () => () => h('div', { class: 'flex items-center gap-3 p8 bg-base color-base font-sans' }, sizes.map(size => h(Button, { variant: 'primary', size }, { default: () => size }))), + }), +} + +/** With a leading icon (via the `#icon` slot). */ +export const WithIcon: Story = { + render: () => ({ + setup: () => () => h('div', { class: 'flex items-center gap-3 p8 bg-base color-base font-sans' }, [ + h(Button, { variant: 'primary' }, { + icon: () => h('div', { class: 'i-ph-shield-check-duotone w-4.5 h-4.5' }), + default: () => 'Authorize', + }), + h(Button, { variant: 'danger' }, { + icon: () => h('div', { class: 'i-ph-trash-duotone w-4.5 h-4.5' }), + default: () => 'Delete', + }), + ]), + }), +} + +/** Loading (spinner + disabled) and disabled states. */ +export const States: Story = { + render: () => ({ + setup: () => () => h('div', { class: 'flex items-center gap-3 p8 bg-base color-base font-sans' }, [ + h(Button, { variant: 'primary', loading: true }, { default: () => 'Authorizing' }), + h(Button, { variant: 'primary', disabled: true }, { default: () => 'Disabled' }), + ]), + }), +} + +/** Full-width block button, as used in the auth form. */ +export const Block: Story = { + render: () => ({ + setup: () => () => h('div', { class: 'max-w-80 p8 bg-base color-base font-sans' }, h(Button, { variant: 'primary', size: 'lg', block: true }, { default: () => 'Authorize' })), + }), +} diff --git a/packages/hub-ui/src/client/components/display/Button.vue b/packages/hub-ui/src/client/components/display/Button.vue new file mode 100644 index 00000000..ace34ece --- /dev/null +++ b/packages/hub-ui/src/client/components/display/Button.vue @@ -0,0 +1,68 @@ + + + diff --git a/packages/hub-ui/src/client/components/display/Confirm.stories.ts b/packages/hub-ui/src/client/components/display/Confirm.stories.ts new file mode 100644 index 00000000..c5cec14c --- /dev/null +++ b/packages/hub-ui/src/client/components/display/Confirm.stories.ts @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h, onMounted } from 'vue' +import { useConfirm } from '../../state/confirm' +import Confirm from './Confirm.vue' + +const meta = { + title: 'Display/Confirm', + component: Confirm, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + // Fixed, full-screen modal — render in an iframe for the docs canvas. + docs: { + story: { inline: false, height: '360px' }, + description: { + component: 'The confirmation dialog, driven by the `useConfirm()` template-promise. These stories trigger it on mount.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function confirmStory(options: { title?: string, message: string, confirmText?: string, cancelText?: string }): Story { + return { + render: () => ({ + setup() { + const confirm = useConfirm() + onMounted(() => { + confirm(options) + }) + return () => h(Confirm) + }, + }), + } +} + +/** A titled destructive confirmation. */ +export const Default: Story = confirmStory({ + title: 'Remove workspace?', + message: 'This deletes the worktree and branch. This cannot be undone.', + confirmText: 'Remove', + cancelText: 'Cancel', +}) + +/** A message-only prompt (no title). */ +export const MessageOnly: Story = confirmStory({ + message: 'Reload the page to apply the new settings?', +}) diff --git a/packages/hub-ui/src/client/components/display/Confirm.vue b/packages/hub-ui/src/client/components/display/Confirm.vue new file mode 100644 index 00000000..2246444d --- /dev/null +++ b/packages/hub-ui/src/client/components/display/Confirm.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/hub-ui/src/client/components/display/HashBadge.stories.ts b/packages/hub-ui/src/client/components/display/HashBadge.stories.ts new file mode 100644 index 00000000..005cd52e --- /dev/null +++ b/packages/hub-ui/src/client/components/display/HashBadge.stories.ts @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import HashBadge from './HashBadge.vue' + +const meta = { + title: 'Display/HashBadge', + component: HashBadge, + tags: ['autodocs'], + decorators: [() => ({ template: '
' })], + args: { label: 'a11y' }, + parameters: { + docs: { + description: { + component: 'A category/label chip whose colour is derived deterministically from the label text.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +/** The colour is stable per string — the same label always gets the same hue. */ +export const Palette: Story = { + render: () => ({ + setup: () => () => h('div', { class: 'flex flex-wrap gap-1.5 max-w-100 p6 font-sans' }, ['a11y', 'lint', 'runtime', 'test', 'network', 'build', 'hmr', 'plugin', 'vite', 'rolldown'] + .map(label => h(HashBadge, { key: label, label }))), + }), +} diff --git a/packages/hub-ui/src/client/components/display/HashBadge.vue b/packages/hub-ui/src/client/components/display/HashBadge.vue new file mode 100644 index 00000000..e1ca1b7e --- /dev/null +++ b/packages/hub-ui/src/client/components/display/HashBadge.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/hub-ui/src/client/components/display/OtpInput.stories.ts b/packages/hub-ui/src/client/components/display/OtpInput.stories.ts new file mode 100644 index 00000000..fa72d2cc --- /dev/null +++ b/packages/hub-ui/src/client/components/display/OtpInput.stories.ts @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { defineComponent, h, ref } from 'vue' +import OtpInput from './OtpInput.vue' + +/** A stateful harness so the controlled `v-model` reflects typing/paste. */ +function harness(props: Record = {}, initial = '') { + return defineComponent({ + setup() { + const code = ref(initial) + return () => h('div', { class: 'p10 bg-base color-base font-sans flex flex-col items-center gap-4' }, [ + h(OtpInput, { 'modelValue': code.value, 'onUpdate:modelValue': (v: string) => (code.value = v), 'autofocus': false, ...props }), + h('div', { class: 'text-sm op-fade font-mono' }, `value: "${code.value}"`), + ]) + }, + }) +} + +const meta = { + title: 'Display/OtpInput', + component: OtpInput, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'An accessible one-time-code input: per-digit boxes with auto-advance, backspace-to-previous, arrow-key navigation, full-code paste, numeric-only entry (`inputmode="numeric"`, `autocomplete="one-time-code"`), per-digit `aria-label`s, and an error state that paints the boxes red and shakes.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Empty — type digits, they auto-advance; paste a full code to fill at once. */ +export const Default: Story = { + render: () => ({ setup: () => () => h(harness()) }), +} + +/** Partially filled. */ +export const Filled: Story = { + render: () => ({ setup: () => () => h(harness({}, '1234')) }), +} + +/** Error state — red boxes and a shake (see the AuthNotice for the full flow). */ +export const Invalid: Story = { + render: () => ({ setup: () => () => h(harness({ invalid: true }, '0042')) }), +} + +/** Disabled. */ +export const Disabled: Story = { + render: () => ({ setup: () => () => h(harness({ disabled: true }, '12')) }), +} + +/** A shorter, four-digit variant. */ +export const FourDigits: Story = { + render: () => ({ setup: () => () => h(harness({ length: 4 })) }), +} diff --git a/packages/hub-ui/src/client/components/display/OtpInput.vue b/packages/hub-ui/src/client/components/display/OtpInput.vue new file mode 100644 index 00000000..572401ba --- /dev/null +++ b/packages/hub-ui/src/client/components/display/OtpInput.vue @@ -0,0 +1,192 @@ + + + diff --git a/packages/hub-ui/src/client/components/display/ToastOverlay.stories.ts b/packages/hub-ui/src/client/components/display/ToastOverlay.stories.ts new file mode 100644 index 00000000..08b29191 --- /dev/null +++ b/packages/hub-ui/src/client/components/display/ToastOverlay.stories.ts @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h, onMounted } from 'vue' +import { addToast } from '../../state/toasts' +import { message } from '../../stories/fixtures' +import ToastOverlay from './ToastOverlay.vue' + +const meta = { + title: 'Messages/ToastOverlay', + component: ToastOverlay, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + // Fixed bottom-right overlay — render in an iframe for the docs canvas. + docs: { + story: { inline: false, height: '360px' }, + description: { + component: 'The toast stack shown bottom-right for `notify` messages. These stories push toasts on mount (with a long auto-dismiss so they stay visible).', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** A few stacked toasts across levels. */ +export const Stack: Story = { + render: () => ({ + setup() { + onMounted(() => { + addToast(message({ level: 'error', message: 'Build failed', description: '2 errors in 1 file', notify: true, autoDismiss: 10 ** 7 })) + addToast(message({ level: 'warn', message: 'Slow HMR update (820ms)', notify: true, autoDismiss: 10 ** 7 })) + addToast(message({ level: 'success', message: 'Server ready on :5173', notify: true, autoDismiss: 10 ** 7 })) + }) + return () => h(ToastOverlay) + }, + }), +} diff --git a/packages/hub-ui/src/client/components/display/ToastOverlay.vue b/packages/hub-ui/src/client/components/display/ToastOverlay.vue new file mode 100644 index 00000000..f9b35ab7 --- /dev/null +++ b/packages/hub-ui/src/client/components/display/ToastOverlay.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/ColorSchemeRoot.vue b/packages/hub-ui/src/client/components/dock/ColorSchemeRoot.vue new file mode 100644 index 00000000..41744e32 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/ColorSchemeRoot.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/Dock.stories.ts b/packages/hub-ui/src/client/components/dock/Dock.stories.ts new file mode 100644 index 00000000..e7779be2 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/Dock.stories.ts @@ -0,0 +1,152 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import type { CreateMockContextOptions } from '../../stories/mock-context' +import type { DockLayout } from './dock-layout' +import { h } from 'vue' +import { categorizedEntries, groupedEntries, overflowEntries } from '../../stories/fixtures' +import { mountWithContext } from '../../stories/story-helpers' +import FloatingElements from '../floating/FloatingElements.vue' +import { DEFAULT_DOCK_LAYOUT } from './dock-layout' +import Dock from './Dock.vue' + +/** + * The story args ARE the dock layout: every field is wired to a Storybook + * control so the bar's dimensions, capacity, spacing and snapping can be tuned + * live from the Controls panel. Individual stories override specific fields to + * showcase a single tunable in isolation. + */ +type LayoutArgs = DockLayout + +const meta = { + title: 'Dock/Shell/Float Bar', + component: Dock, + tags: ['autodocs'], + args: { ...DEFAULT_DOCK_LAYOUT }, + argTypes: { + barHeight: { control: { type: 'range', min: 24, max: 80, step: 1 }, description: 'Height of the bar (px).' }, + barMinWidth: { control: { type: 'range', min: 60, max: 220, step: 1 }, description: 'Minimum bar width before content (px).' }, + minimizedSize: { control: { type: 'range', min: 14, max: 40, step: 1 }, description: 'Side length of the collapsed nub (px).' }, + glowSize: { control: { type: 'range', min: 60, max: 320, step: 1 }, description: 'Diameter of the ambient glow (px).' }, + glowBlur: { control: { type: 'range', min: 0, max: 120, step: 1 }, description: 'Blur radius of the glow (px).' }, + maxVisibleItems: { control: { type: 'range', min: 1, max: 12, step: 1 }, description: 'Inline item capacity before overflow.' }, + viewportMargin: { control: { type: 'range', min: 0, max: 48, step: 1 }, description: 'Gap from the viewport edge (px).' }, + panelOverlapFactor: { control: { type: 'range', min: 0, max: 1, step: 0.05 }, description: 'Dock↔panel overlap (fraction of bar thickness). Visible with the panel open — see the Embedded stories.' }, + edgeSnapPercent: { control: { type: 'range', min: 0, max: 20, step: 1 }, description: 'Snap-to-edge zone (viewport %).' }, + centerSnapPercent: { control: { type: 'range', min: 0, max: 20, step: 1 }, description: 'Snap-to-center zone (viewport %).' }, + edgeZoneHeight: { control: { type: 'range', min: 0, max: 200, step: 1 }, description: 'Top/bottom edge-detection zone height (px).' }, + }, + parameters: { + layout: 'fullscreen', + // The shell is `position: fixed` to the viewport, so it escapes the small + // inline docs preview block — render it in an iframe of a fixed height. + docs: { + story: { inline: false, height: '520px' }, + description: { + component: 'The floating dock bar (float mode). It anchors to an edge of the viewport and can be dragged around. Its padding, sizing, spacing, capacity and snapping are driven by the `DockLayout` constants (`dock-layout.ts`) — every field is exposed here as a live control. These stories pin `inactiveTimeout: -1` so the bar stays expanded.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** + * Build a float-bar story whose layout is driven by the live story args, so the + * Controls panel tunes the same `DockLayout` the runtime uses. + */ +function floatStory(options: CreateMockContextOptions): Story { + return { + render: (args: LayoutArgs) => ({ + setup: () => mountWithContext( + options, + ctx => [h(Dock, { context: ctx, layout: args }), h(FloatingElements)], + ), + }), + } +} + +/** Default: docked to the bottom-center of the viewport. */ +export const Bottom: Story = floatStory( + { entries: categorizedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }, +) + +/** Docked to the top edge. */ +export const Top: Story = floatStory( + { entries: categorizedEntries, panel: { position: 'top', left: 50, top: 0, inactiveTimeout: -1 } }, +) + +/** Docked to the left edge — the bar rotates to vertical. */ +export const Left: Story = floatStory( + { entries: categorizedEntries, panel: { position: 'left', left: 0, top: 50, inactiveTimeout: -1 } }, +) + +/** Docked to the right edge. */ +export const Right: Story = floatStory( + { entries: categorizedEntries, panel: { position: 'right', left: 100, top: 50, inactiveTimeout: -1 } }, +) + +/** With collapsed groups on the bar. */ +export const WithGroups: Story = floatStory( + { entries: groupedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }, +) + +/** Past capacity: an overflow button appears at the end of the bar. */ +export const WithOverflow: Story = floatStory( + { entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }, +) + +/** Collapsed to the minimized nub (`inactiveTimeout: 0`). */ +export const Minimized: Story = floatStory( + { entries: categorizedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: 0 } }, +) + +/** Unauthorized: the bar shows the warning affordance instead of the tools. */ +export const Unauthorized: Story = { + ...floatStory({ entries: categorizedEntries, isTrusted: false, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }), +} + +// --- Layout tunables --------------------------------------------------------- +// Each of the following overrides a single `DockLayout` field to demonstrate +// how the bar responds. Adjust the Controls panel to combine them. + +/** `barHeight: 56` — a taller bar (also grows the panel-to-dock offset). */ +export const TallBar: Story = { + ...floatStory({ entries: categorizedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }), + args: { barHeight: 56 }, +} + +/** `barHeight: 30`, `minimizedSize: 18` — a compact bar. */ +export const CompactBar: Story = { + ...floatStory({ entries: categorizedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }), + args: { barHeight: 30, minimizedSize: 18 }, +} + +/** `viewportMargin: 28` — a roomy gap between the bar and the viewport edge. */ +export const RoomyViewportMargin: Story = { + ...floatStory({ entries: categorizedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }), + args: { viewportMargin: 28 }, +} + +/** `maxVisibleItems: 3` — a lower capacity forces more entries into overflow. */ +export const LowerCapacity: Story = { + ...floatStory({ entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }), + args: { maxVisibleItems: 3 }, +} + +/** `maxVisibleItems: 9` — a higher capacity absorbs the overflow set inline. */ +export const HigherCapacity: Story = { + ...floatStory({ entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }), + args: { maxVisibleItems: 9 }, +} + +/** `maxVisibleItems: 8` — exactly one entry overflows, so it renders inline instead of behind the overflow button. */ +export const SingleOverflowItem: Story = { + ...floatStory({ entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }), + args: { maxVisibleItems: 8 }, +} + +/** `glowSize: 280`, `glowBlur: 90` — a larger, softer ambient glow. */ +export const LargeGlow: Story = { + ...floatStory({ entries: categorizedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }), + args: { glowSize: 280, glowBlur: 90 }, +} diff --git a/packages/hub-ui/src/client/components/dock/Dock.vue b/packages/hub-ui/src/client/components/dock/Dock.vue new file mode 100644 index 00000000..c5983b7a --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/Dock.vue @@ -0,0 +1,329 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockContextMenu.ts b/packages/hub-ui/src/client/components/dock/DockContextMenu.ts new file mode 100644 index 00000000..cdf2ae49 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockContextMenu.ts @@ -0,0 +1,135 @@ +import type { DevframeDockEntry } from '@devframes/hub' +import type { DocksContext } from '@devframes/hub/client' +import { h } from 'vue' +import { setDockContextMenu } from '../../state/floating-tooltip' +import { isDockPopupSupported, requestDockPopupOpen, useIsDockPopupOpen } from '../../state/popup' + +// @unocss-include + +interface DockMenuItem { + label: string + icon: string + action: () => void + visible: boolean +} + +function renderMenuItem(item: DockMenuItem) { + return h('button', { + class: 'flex items-center gap-2 px3 py1.5 rounded hover:bg-active transition text-left', + onClick: item.action, + }, [ + h('div', { class: `${item.icon} text-base op60` }), + h('span', item.label), + ]) +} + +function hideDock(context: DocksContext, entry: DevframeDockEntry) { + const settingsStore = context.docks.settings + const id = entry.id + settingsStore.mutate((state) => { + if (!state.docksHidden.includes(id)) + state.docksHidden = [...state.docksHidden, id] + }) + if (context.docks.selected?.id === id) + context.docks.switchEntry(null) + setDockContextMenu(null) +} + +function refreshDock(context: DocksContext, entry: DevframeDockEntry) { + const state = context.docks.getStateById(entry.id) + const iframe = state?.domElements.iframe + if (!iframe) { + setDockContextMenu(null) + return + } + const src = iframe.src + iframe.src = '' + iframe.src = src + setDockContextMenu(null) +} + +function canHide(context: DocksContext, entry: DevframeDockEntry) { + if (entry.id === '~settings') + return false + return context.docks.entries.some(item => item.id === entry.id) +} + +function canRefresh(entry: DevframeDockEntry) { + return entry.type === 'iframe' +} + +export function openDockContextMenu(options: { + context: DocksContext + entry: DevframeDockEntry + el: HTMLElement + gap?: number +}) { + const { context, entry, el, gap = 6 } = options + const isEdgeMode = context.panel.store.mode === 'edge' + const items: DockMenuItem[] = [ + { + label: 'Hide', + icon: 'i-ph-eye-slash-duotone', + action: () => hideDock(context, entry), + visible: canHide(context, entry), + }, + { + label: 'Refresh', + icon: 'i-ph-arrow-clockwise-duotone', + action: () => refreshDock(context, entry), + visible: canRefresh(entry), + }, + { + label: isEdgeMode ? 'Float Mode' : 'Edge Mode', + icon: isEdgeMode ? 'i-ph-arrows-out-duotone' : 'i-ph-square-half-bottom-duotone', + action: () => { + if (isEdgeMode) { + // Reset float position defaults based on current edge position + const store = context.panel.store + switch (store.position) { + case 'bottom': + store.left = 50 + store.top = 100 + break + case 'top': + store.left = 50 + store.top = 0 + break + case 'left': + store.left = 0 + store.top = 50 + break + case 'right': + store.left = 100 + store.top = 50 + break + } + store.mode = 'float' + } + else { + context.panel.store.mode = 'edge' + } + setDockContextMenu(null) + }, + visible: context.clientType === 'embedded', + }, + { + label: 'Popup', + icon: 'i-ph-arrow-square-out-duotone', + action: () => { + setDockContextMenu(null) + requestDockPopupOpen(context) + }, + visible: isDockPopupSupported() && !useIsDockPopupOpen().value && context.clientType === 'embedded', + }, + ].filter(item => item.visible) + + if (items.length === 0) + return + + setDockContextMenu({ + el, + gap, + content: () => h('div', { class: 'flex flex-col text-sm min-w-36 mx--1' }, items.map(renderMenuItem)), + }) +} diff --git a/packages/hub-ui/src/client/components/dock/DockEdge.stories.ts b/packages/hub-ui/src/client/components/dock/DockEdge.stories.ts new file mode 100644 index 00000000..439ee4e6 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEdge.stories.ts @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { categorizedEntries, groupedEntries } from '../../stories/fixtures' +import { mountWithContext } from '../../stories/story-helpers' +import FloatingElements from '../floating/FloatingElements.vue' +import DockEdge from './DockEdge.vue' + +/** A stand-in panel body (the real one mounts iframe/custom views). */ +function body(entry: any) { + return h('div', { class: 'w-full h-full p6 font-sans color-base of-auto' }, [ + h('div', { class: 'text-lg font-medium mb2' }, entry?.title ?? 'No selection'), + h('div', { class: 'op60 text-sm' }, `Panel content for "${entry?.id ?? '—'}"`), + ]) +} + +const meta = { + title: 'Dock/Shell/Edge Panel', + component: DockEdge, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + story: { inline: false, height: '520px' }, + description: { + component: 'The edge-docked shell (edge mode): a toolbar pinned to one viewport edge with a resizable panel. The `#view` slot is stubbed here in place of the live view renderer.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function edgeStory(position: 'top' | 'right' | 'bottom' | 'left', open = true) { + return { + render: () => ({ + setup: () => mountWithContext( + { + entries: categorizedEntries, + selectedId: open ? 'overview' : null, + panel: { mode: 'edge', position, open, height: 40, width: 30 }, + }, + ctx => [ + h(DockEdge, { context: ctx }, { view: ({ entry }: any) => body(entry) }), + h(FloatingElements), + ], + ), + }), + } satisfies Story +} + +/** Bottom edge with the panel open. */ +export const Bottom: Story = edgeStory('bottom') + +/** Top edge. */ +export const Top: Story = edgeStory('top') + +/** Left edge — toolbar runs vertically. */ +export const Left: Story = edgeStory('left') + +/** Right edge. */ +export const Right: Story = edgeStory('right') + +/** Toolbar only — nothing selected, so the panel body is collapsed away. */ +export const ToolbarOnly: Story = edgeStory('bottom', false) + +/** Edge dock hosting a group — the group rail shows inside the panel. */ +export const WithGroup: Story = { + render: () => ({ + setup: () => mountWithContext( + { + entries: groupedEntries, + selectedId: 'nuxt:overview', + panel: { mode: 'edge', position: 'bottom', open: true, height: 45 }, + }, + ctx => [ + h(DockEdge, { context: ctx }, { view: ({ entry }: any) => body(entry) }), + h(FloatingElements), + ], + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockEdge.vue b/packages/hub-ui/src/client/components/dock/DockEdge.vue new file mode 100644 index 00000000..5d589db2 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEdge.vue @@ -0,0 +1,292 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockEmbedded.stories.ts b/packages/hub-ui/src/client/components/dock/DockEmbedded.stories.ts new file mode 100644 index 00000000..a3c110ef --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEmbedded.stories.ts @@ -0,0 +1,86 @@ +import type { DevframeDockEntry } from '@devframes/hub' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { categorizedEntries } from '../../stories/fixtures' +import { mountWithContext } from '../../stories/story-helpers' +import DockEmbedded from './DockEmbedded.vue' + +// Entries whose iframes load `about:blank`, so the full shell (address bar + +// iframe pane) renders without reaching for a dev server. +const blankEntries: DevframeDockEntry[] = [ + { id: 'overview', type: 'iframe', url: 'about:blank', title: 'Overview', icon: 'ph:gauge-duotone' }, + { id: 'inspect', type: 'iframe', url: 'about:blank', title: 'Inspect', icon: 'ph:stethoscope-duotone' }, + { id: 'assets', type: 'iframe', url: 'about:blank', title: 'Assets', icon: 'ph:images-duotone' }, +] as DevframeDockEntry[] + +const meta = { + title: 'Dock/Shell/Embedded', + component: DockEmbedded, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + story: { inline: false, height: '540px' }, + description: { + component: 'The full embedded shell as injected into a host app: the dock bar plus panel (float or edge), floating overlays, command palette, toasts and confirm dialog — switched by `panel.store.mode`.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Float mode, panel closed — just the resting dock bar. */ +export const FloatClosed: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: categorizedEntries, panel: { mode: 'float', position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }, + ctx => h(DockEmbedded, { context: ctx }), + ), + }), +} + +/** Float mode with the panel open over an `about:blank` iframe. */ +export const FloatOpen: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: blankEntries, selectedId: 'overview', panel: { mode: 'float', position: 'bottom', left: 50, top: 100, inactiveTimeout: -1, width: 60, height: 60 } }, + ctx => h(DockEmbedded, { context: ctx }), + ), + }), +} + +/** + * `panelOverlapFactor: 0.2` — the panel slides clear of the dock bar, leaving + * most of the bar hanging past the panel edge so the iframe underneath stays + * readable (compare with `FloatOpen`, which uses the default `0.5`). + */ +export const FloatReducedOverlap: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: blankEntries, selectedId: 'overview', panel: { mode: 'float', position: 'bottom', left: 50, top: 100, inactiveTimeout: -1, width: 60, height: 60 } }, + ctx => h(DockEmbedded, { context: ctx, layout: { panelOverlapFactor: 0.2 } }), + ), + }), +} + +/** Edge mode, docked to the bottom with the panel open. */ +export const Edge: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: blankEntries, selectedId: 'overview', panel: { mode: 'edge', position: 'bottom', open: true, height: 45 } }, + ctx => h(DockEmbedded, { context: ctx }), + ), + }), +} + +/** Unauthorized — the shell forces float mode and shows the warning. */ +export const Unauthorized: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: categorizedEntries, isTrusted: false, panel: { mode: 'edge', position: 'bottom', inactiveTimeout: -1 } }, + ctx => h(DockEmbedded, { context: ctx }), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockEmbedded.vue b/packages/hub-ui/src/client/components/dock/DockEmbedded.vue new file mode 100644 index 00000000..ca3fd6d2 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEmbedded.vue @@ -0,0 +1,80 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockEntries.vue b/packages/hub-ui/src/client/components/dock/DockEntries.vue new file mode 100644 index 00000000..db5661c2 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEntries.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockEntriesWithCategories.stories.ts b/packages/hub-ui/src/client/components/dock/DockEntriesWithCategories.stories.ts new file mode 100644 index 00000000..4b7c3117 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEntriesWithCategories.stories.ts @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { categorizedEntries, groupedEntries } from '../../stories/fixtures' +import { mountWithContext, stage } from '../../stories/story-helpers' +import DockEntriesWithCategories from './DockEntriesWithCategories.vue' + +/** Horizontal dock-bar pill wrapper. */ +function bar(children: any, vertical = false) { + return h('div', { + class: [ + 'flex items-center gap-0.5 p1.5 rounded-full bg-glass border border-base shadow color-base', + vertical ? 'flex-col' : 'flex-row', + ], + }, children) +} + +const meta = { + title: 'Dock/Bar/EntriesWithCategories', + component: DockEntriesWithCategories, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +/** + * Entries spanning several categories. Categories are ordered by the built-in + * ranking and separated by a divider. + */ +export const Categories: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: categorizedEntries }, + ctx => stage(bar(h(DockEntriesWithCategories, { + context: ctx, + groups: ctx.docks.groupedEntries, + selected: ctx.docks.selected, + isVertical: false, + onSelect: (e: any) => ctx.docks.switchEntry(e?.id), + }))), + ), + }), +} + +/** + * A bar with collapsed groups — the group members fold behind their group + * button and only the group icon shows on the bar. + */ +export const WithGroups: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries }, + ctx => stage(bar(h(DockEntriesWithCategories, { + context: ctx, + groups: ctx.docks.groupedEntries, + selected: ctx.docks.selected, + isVertical: false, + onSelect: (e: any) => ctx.docks.switchEntry(e?.id), + }))), + ), + }), +} + +/** The same bar rotated for a left/right edge dock. */ +export const Vertical: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: categorizedEntries }, + ctx => stage(bar(h(DockEntriesWithCategories, { + context: ctx, + groups: ctx.docks.groupedEntries, + selected: ctx.docks.selected, + isVertical: true, + }), true)), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockEntriesWithCategories.vue b/packages/hub-ui/src/client/components/dock/DockEntriesWithCategories.vue new file mode 100644 index 00000000..ca95910b --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEntriesWithCategories.vue @@ -0,0 +1,38 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockEntry.stories.ts b/packages/hub-ui/src/client/components/dock/DockEntry.stories.ts new file mode 100644 index 00000000..5eca6025 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEntry.stories.ts @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { basicEntries, iframe } from '../../stories/fixtures' +import { mountWithContext, stage } from '../../stories/story-helpers' +import DockEntry from './DockEntry.vue' + +const sample = iframe('overview', 'Overview', 'ph:gauge-duotone') + +interface EntryArgs { + isSelected?: boolean + isDimmed?: boolean + isVertical?: boolean + isAction?: boolean + badge?: string + tooltip?: boolean +} + +/** A pill container that echoes the dock bar, so the button reads in context. */ +function pill(children: any) { + return h('div', { class: 'flex items-center gap-1 p1.5 rounded-full bg-glass border border-base shadow' }, children) +} + +const meta = { + title: 'Dock/Entry', + component: DockEntry, + tags: ['autodocs'], + args: { + isSelected: false, + isDimmed: false, + isVertical: false, + isAction: false, + badge: '', + tooltip: true, + }, + render: (args: EntryArgs) => ({ + setup: () => mountWithContext( + { entries: basicEntries }, + ctx => stage(pill(h(DockEntry, { + context: ctx, + dock: sample, + isSelected: args.isSelected, + isDimmed: args.isDimmed, + isVertical: args.isVertical, + isAction: args.isAction, + badge: args.badge || undefined, + tooltip: args.tooltip, + }))), + ), + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Idle button — the resting state on the dock bar. */ +export const Default: Story = {} + +/** The active entry: scaled up and tinted while its panel owns the screen. */ +export const Selected: Story = { args: { isSelected: true } } + +/** Dimmed and desaturated because a sibling entry is the active one. */ +export const Dimmed: Story = { args: { isDimmed: true } } + +/** Action entries (one-shot commands) get a circular, filled treatment. */ +export const Action: Story = { args: { isAction: true } } + +/** A count badge in the corner (e.g. pending items). */ +export const WithBadge: Story = { args: { badge: '3' } } + +/** Rotated for a left/right edge dock. */ +export const Vertical: Story = { args: { isVertical: true } } + +/** Every state laid out together for a quick visual diff. */ +export const States: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: basicEntries }, + ctx => stage(pill([ + h(DockEntry, { context: ctx, dock: sample }), + h(DockEntry, { context: ctx, dock: sample, isSelected: true }), + h(DockEntry, { context: ctx, dock: sample, isDimmed: true }), + h(DockEntry, { context: ctx, dock: sample, isAction: true }), + h(DockEntry, { context: ctx, dock: sample, badge: '9+' }), + ])), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockEntry.vue b/packages/hub-ui/src/client/components/dock/DockEntry.vue new file mode 100644 index 00000000..20eaa1e2 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockEntry.vue @@ -0,0 +1,102 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockGroupButton.stories.ts b/packages/hub-ui/src/client/components/dock/DockGroupButton.stories.ts new file mode 100644 index 00000000..bd2f6d76 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockGroupButton.stories.ts @@ -0,0 +1,93 @@ +import type { DevframeViewGroup } from '@devframes/hub' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { groupedEntries } from '../../stories/fixtures' +import { mountWithContext, stage } from '../../stories/story-helpers' +import FloatingElements from '../floating/FloatingElements.vue' +import DockGroupButton from './DockGroupButton.vue' + +const nuxtGroup = groupedEntries.find(e => e.id === 'nuxt') as DevframeViewGroup +const playgroundGroup = groupedEntries.find(e => e.id === 'playground') as DevframeViewGroup + +function bar(children: any) { + return h('div', { class: 'flex items-center gap-0.5 p1.5 rounded-full bg-glass border border-base shadow color-base' }, children) +} + +const meta = { + title: 'Dock/Group/Button', + component: DockGroupButton, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'The dock-bar button representing a group. Click behaviour depends on the group: a group with `defaultChildId` opens that member directly, otherwise it reveals a popover of members. `FloatingElements` is mounted alongside so the popover renders.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** + * A popover-only group (no `defaultChildId`): clicking reveals the member + * popover. + */ +export const PopoverOnly: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries }, + ctx => stage([ + bar(h(DockGroupButton, { + context: ctx, + group: playgroundGroup, + isVertical: false, + selected: ctx.docks.selected, + onSelect: (e: any) => ctx.docks.switchEntry(e?.id), + })), + h(FloatingElements), + ]), + ), + }), +} + +/** + * A group with a `defaultChildId`: clicking opens that member straight away + * instead of showing the popover. + */ +export const WithDefaultChild: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries }, + ctx => stage([ + bar(h(DockGroupButton, { + context: ctx, + group: nuxtGroup, + isVertical: false, + selected: ctx.docks.selected, + onSelect: (e: any) => ctx.docks.switchEntry(e?.id), + })), + h(FloatingElements), + ]), + ), + }), +} + +/** Active state — a member of the group currently owns the panel. */ +export const Active: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries, selectedId: 'nuxt:pages' }, + ctx => stage([ + bar(h(DockGroupButton, { + context: ctx, + group: nuxtGroup, + isVertical: false, + selected: ctx.docks.selected, + onSelect: (e: any) => ctx.docks.switchEntry(e?.id), + })), + h(FloatingElements), + ]), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockGroupButton.vue b/packages/hub-ui/src/client/components/dock/DockGroupButton.vue new file mode 100644 index 00000000..4bec2bbd --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockGroupButton.vue @@ -0,0 +1,134 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockGroupPopover.stories.ts b/packages/hub-ui/src/client/components/dock/DockGroupPopover.stories.ts new file mode 100644 index 00000000..3f91acd8 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockGroupPopover.stories.ts @@ -0,0 +1,107 @@ +import type { DevframeViewGroup } from '@devframes/hub' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants' +import { h } from 'vue' +import { getGroupMembersGrouped } from '../../state/dock-settings' +import { group, groupedEntries, subcategorizedGroupEntries, toolsGroup } from '../../stories/fixtures' +import { mountWithContext, stage } from '../../stories/story-helpers' +import DockGroupPopover from './DockGroupPopover.vue' + +const settings = DEFAULT_STATE_USER_SETTINGS() +const nuxtGroup = groupedEntries.find(e => e.id === 'nuxt') as DevframeViewGroup +// Members split by in-group sub-category (the shape the popover renders). +const nuxtMembers = getGroupMembersGrouped(groupedEntries, 'nuxt', settings) +const toolsMembers = getGroupMembersGrouped(subcategorizedGroupEntries, 'tools', settings) + +/** A framed surface that stands in for the floating popover container. */ +function popover(children: any) { + return h('div', { class: 'bg-glass color-base border border-base rounded-lg shadow p1 font-sans' }, children) +} + +const meta = { + title: 'Dock/Group/Popover', + component: DockGroupPopover, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +/** The group's members listed under its heading; click to select one. */ +export const Default: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries }, + ctx => stage(popover(h(DockGroupPopover, { + context: ctx, + group: nuxtGroup, + members: nuxtMembers, + selectedId: ctx.docks.selectedId, + onSelect: (entry: any) => ctx.docks.switchEntry(entry.id), + }))), + ), + }), +} + +/** With one member already active — it gets the tinted, highlighted row. */ +export const WithSelection: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries, selectedId: 'nuxt:pages' }, + ctx => stage(popover(h(DockGroupPopover, { + context: ctx, + group: nuxtGroup, + members: nuxtMembers, + selectedId: ctx.docks.selectedId, + onSelect: (entry: any) => ctx.docks.switchEntry(entry.id), + }))), + ), + }), +} + +/** A member carrying a count badge. */ +export const WithBadge: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries }, + ctx => stage(popover(h(DockGroupPopover, { + context: ctx, + group: nuxtGroup, + members: nuxtMembers, + selectedId: 'nuxt:components', + onSelect: (entry: any) => ctx.docks.switchEntry(entry.id), + }))), + ), + }), +} + +/** Members split across in-group sub-categories, shown with section dividers. */ +export const WithSubcategories: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: subcategorizedGroupEntries }, + ctx => stage(popover(h(DockGroupPopover, { + context: ctx, + group: toolsGroup, + members: toolsMembers, + selectedId: ctx.docks.selectedId, + onSelect: (entry: any) => ctx.docks.switchEntry(entry.id), + }))), + ), + }), +} + +/** An empty group falls back to the "No tools yet" placeholder. */ +export const Empty: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: [group('empty', 'Empty', 'ph:folder-dashed-duotone')] }, + ctx => stage(popover(h(DockGroupPopover, { + context: ctx, + group: group('empty', 'Empty', 'ph:folder-dashed-duotone') as DevframeViewGroup, + members: [], + selectedId: null, + }))), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockGroupPopover.vue b/packages/hub-ui/src/client/components/dock/DockGroupPopover.vue new file mode 100644 index 00000000..8abd655c --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockGroupPopover.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockGroupSidebar.stories.ts b/packages/hub-ui/src/client/components/dock/DockGroupSidebar.stories.ts new file mode 100644 index 00000000..928853cf --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockGroupSidebar.stories.ts @@ -0,0 +1,75 @@ +import type { DevframeViewGroup } from '@devframes/hub' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { groupedEntries, subcategorizedGroupEntries, toolsGroup } from '../../stories/fixtures' +import { mountWithContext, stage } from '../../stories/story-helpers' +import DockGroupSidebar from './DockGroupSidebar.vue' + +const nuxtGroup = groupedEntries.find(e => e.id === 'nuxt') as DevframeViewGroup + +/** A bordered shell that mimics the panel the sidebar lives inside. */ +function shell(children: any, heightClass = 'h-80') { + return h('div', { class: `flex ${heightClass} bg-glass color-base border border-base rounded-lg shadow overflow-hidden font-sans` }, [ + children, + h('div', { class: 'flex-1 flex items-center justify-center op40 text-sm' }, 'panel body'), + ]) +} + +const meta = { + title: 'Dock/Group/Sidebar', + component: DockGroupSidebar, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +/** + * The group rail shown down the side of a panel when the active entry belongs + * to a group — the group anchor on top, its members below. + */ +export const Default: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries, selectedId: 'nuxt:overview' }, + ctx => stage(shell(h(DockGroupSidebar, { + context: ctx, + group: nuxtGroup, + selectedId: ctx.docks.selectedId, + }))), + ), + }), +} + +/** A different member active — the highlight follows the selection. */ +export const WithSelection: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries, selectedId: 'nuxt:components' }, + ctx => stage(shell(h(DockGroupSidebar, { + context: ctx, + group: nuxtGroup, + selectedId: ctx.docks.selectedId, + }))), + ), + }), +} + +/** + * A short frame folds the members that don't fit into a bottom "show more" + * button (with a count badge). Here the active member (`tools:graph`) lands in + * the overflow, so the show-more button is highlighted to flag the hidden + * selection. + */ +export const Overflow: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: subcategorizedGroupEntries, selectedId: 'tools:graph' }, + ctx => stage(shell(h(DockGroupSidebar, { + context: ctx, + group: toolsGroup, + selectedId: ctx.docks.selectedId, + }), 'h-44')), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue b/packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue new file mode 100644 index 00000000..bab673ef --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue @@ -0,0 +1,197 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockIcon.stories.ts b/packages/hub-ui/src/client/components/dock/DockIcon.stories.ts new file mode 100644 index 00000000..d21d7d6f --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockIcon.stories.ts @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import DockIcon from './DockIcon.vue' + +const meta = { + title: 'Dock/Icon', + component: DockIcon, + tags: ['autodocs'], + // Render at a legible size — DockIcon fills its box. Use the template-based + // `` decorator (the stable Storybook Vue API). + decorators: [() => ({ template: '
' })], + argTypes: { + icon: { control: false }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** An Iconify collection reference (`collection:name`), fetched on demand. */ +export const Iconify: Story = { + args: { icon: 'ph:gauge-duotone' }, +} + +/** A logo-style Iconify icon, as a framework group would use. */ +export const Logo: Story = { + args: { icon: 'logos:nuxt-icon' }, +} + +/** + * An object icon with distinct light/dark variants — only the one matching the + * active theme is shown (toggle the theme toolbar to compare). + */ +export const LightDark: Story = { + args: { icon: { light: 'ph:sun-duotone', dark: 'ph:moon-duotone' } }, +} + +/** The bundled Devframes brand mark, referenced by the sentinel `builtin:` id. */ +export const BuiltinBrand: Story = { + args: { icon: 'builtin:devframes' }, +} + +/** A raw image URL (here an inline data URI) rendered via ``. */ +export const ImageUrl: Story = { + args: { + icon: `data:image/svg+xml,${ + encodeURIComponent('')}`, + }, +} diff --git a/packages/hub-ui/src/client/components/dock/DockIcon.vue b/packages/hub-ui/src/client/components/dock/DockIcon.vue new file mode 100644 index 00000000..6b29c251 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockIcon.vue @@ -0,0 +1,33 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockOverflowButton.stories.ts b/packages/hub-ui/src/client/components/dock/DockOverflowButton.stories.ts new file mode 100644 index 00000000..698775e8 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockOverflowButton.stories.ts @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { docksSplitGroupsWithCapacity } from '../../state/dock-settings' +import { overflowEntries } from '../../stories/fixtures' +import { mountWithContext, stage } from '../../stories/story-helpers' +import FloatingElements from '../floating/FloatingElements.vue' +import DockOverflowButton from './DockOverflowButton.vue' + +function bar(children: any) { + return h('div', { class: 'flex items-center gap-0.5 p1.5 rounded-full bg-glass border border-base shadow color-base' }, children) +} + +const meta = { + title: 'Dock/Bar/OverflowButton', + component: DockOverflowButton, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Shown when the bar exceeds its slot capacity. Carries a badge with the hidden count and reveals the remaining entries in a popover on click.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Enough entries to overflow the 5-slot capacity; the remainder go behind the button. */ +export const Default: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: overflowEntries }, + (ctx) => { + const overflow = docksSplitGroupsWithCapacity(ctx.docks.groupedEntries, 5).overflow + return stage([ + bar(h(DockOverflowButton, { + context: ctx, + isVertical: false, + groups: overflow, + selected: ctx.docks.selected, + onSelect: (e: any) => ctx.docks.switchEntry(e?.id), + })), + h(FloatingElements), + ]) + }, + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockOverflowButton.vue b/packages/hub-ui/src/client/components/dock/DockOverflowButton.vue new file mode 100644 index 00000000..d1eb524b --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockOverflowButton.vue @@ -0,0 +1,98 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockPanel.stories.ts b/packages/hub-ui/src/client/components/dock/DockPanel.stories.ts new file mode 100644 index 00000000..6e3b7260 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockPanel.stories.ts @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { categorizedEntries, groupedEntries } from '../../stories/fixtures' +import { mountWithContext } from '../../stories/story-helpers' +import DockPanel from './DockPanel.vue' + +const MARGINS = { left: 2, top: 2, right: 2, bottom: 2 } + +function body(entry: any) { + return h('div', { class: 'w-full h-full p6 font-sans color-base of-auto' }, [ + h('div', { class: 'text-lg font-medium mb2' }, entry?.title ?? 'No selection'), + h('div', { class: 'op60 text-sm' }, `Panel content for "${entry?.id ?? '—'}"`), + ]) +} + +const meta = { + title: 'Dock/Shell/Float Panel', + component: DockPanel, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + story: { inline: false, height: '540px' }, + description: { + component: 'The floating panel shown above the dock bar in float mode. The `#view` slot is stubbed here in place of the live view renderer.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** A plain entry selected, panel anchored to the bottom. */ +export const Default: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: categorizedEntries, selectedId: 'overview', panel: { position: 'bottom', width: 60, height: 60 } }, + ctx => h(DockPanel, { + context: ctx, + selected: ctx.docks.selected, + panelMargins: MARGINS, + }, { view: ({ entry }: any) => body(entry) }), + ), + }), +} + +/** A group member selected — the group rail appears down the left of the panel. */ +export const WithGroupSidebar: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries, selectedId: 'nuxt:overview', panel: { position: 'bottom', width: 60, height: 60 } }, + ctx => h(DockPanel, { + context: ctx, + selected: ctx.docks.selected, + panelMargins: MARGINS, + }, { view: ({ entry }: any) => body(entry) }), + ), + }), +} + +/** Anchored to the right edge. */ +export const RightAnchored: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: categorizedEntries, selectedId: 'overview', panel: { position: 'right', width: 40, height: 80 } }, + ctx => h(DockPanel, { + context: ctx, + selected: ctx.docks.selected, + panelMargins: MARGINS, + }, { view: ({ entry }: any) => body(entry) }), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockPanel.vue b/packages/hub-ui/src/client/components/dock/DockPanel.vue new file mode 100644 index 00000000..7785f3c2 --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockPanel.vue @@ -0,0 +1,212 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockPanelResizer.vue b/packages/hub-ui/src/client/components/dock/DockPanelResizer.vue new file mode 100644 index 00000000..1d99bacc --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockPanelResizer.vue @@ -0,0 +1,144 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/DockStandalone.stories.ts b/packages/hub-ui/src/client/components/dock/DockStandalone.stories.ts new file mode 100644 index 00000000..b467a98c --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockStandalone.stories.ts @@ -0,0 +1,63 @@ +import type { DevframeDockEntry } from '@devframes/hub' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { mountWithContext } from '../../stories/story-helpers' +import DockStandalone from './DockStandalone.vue' + +// Standalone renders the selected entry's view; use about:blank iframes so the +// shell renders without a dev server. +const blankEntries: DevframeDockEntry[] = [ + { id: 'overview', type: 'iframe', url: 'about:blank', title: 'Overview', icon: 'ph:gauge-duotone', category: 'app' }, + { id: 'routes', type: 'iframe', url: 'about:blank', title: 'Routes', icon: 'ph:signpost-duotone', category: 'app' }, + { id: 'nuxt', type: 'group', title: 'Nuxt', icon: 'logos:nuxt-icon', category: 'framework', defaultChildId: 'nuxt:overview' }, + { id: 'nuxt:overview', type: 'iframe', url: 'about:blank', title: 'Overview', icon: 'ph:gauge-duotone', groupId: 'nuxt' }, + { id: 'nuxt:pages', type: 'iframe', url: 'about:blank', title: 'Pages', icon: 'ph:files-duotone', groupId: 'nuxt' }, +] as DevframeDockEntry[] + +const meta = { + title: 'Dock/Shell/Standalone', + component: DockStandalone, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + story: { inline: false, height: '560px' }, + description: { + component: 'The standalone shell: a full-window sidebar + view layout (no floating dock), used when Devframes runs in its own page/window.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Sidebar of tools with the first entry auto-selected. */ +export const Default: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: blankEntries, clientType: 'standalone' }, + ctx => h(DockStandalone, { context: ctx }), + ), + }), +} + +/** A group member selected — the group rail appears beside the view. */ +export const WithGroup: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: blankEntries, clientType: 'standalone', selectedId: 'nuxt:overview' }, + ctx => h(DockStandalone, { context: ctx }), + ), + }), +} + +/** Unauthorized — the standalone window shows the auth notice. */ +export const Unauthorized: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: blankEntries, clientType: 'standalone', isTrusted: false }, + ctx => h(DockStandalone, { context: ctx }), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/dock/DockStandalone.vue b/packages/hub-ui/src/client/components/dock/DockStandalone.vue new file mode 100644 index 00000000..68b44f7d --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/DockStandalone.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/hub-ui/src/client/components/dock/dock-layout.ts b/packages/hub-ui/src/client/components/dock/dock-layout.ts new file mode 100644 index 00000000..d21d2fac --- /dev/null +++ b/packages/hub-ui/src/client/components/dock/dock-layout.ts @@ -0,0 +1,235 @@ +/** + * Layout tunables for the floating dock bar (`Dock.vue`). + * + * Every spatial magic number the float-mode shell relies on lives here — bar + * dimensions, viewport spacing, item capacity and drag-snapping behaviour — so + * the bar's look and feel can be adjusted from a single place instead of being + * scattered across the component's template, script and stylesheet. + * + * Pixel values are logical CSS pixels. Percent values are relative to the + * viewport (the panel store persists `left`/`top` as `0`–`100` percentages). + * The dimension fields ({@link DockLayout.barHeight} and friends) are projected + * to CSS custom properties by {@link dockLayoutCssVars} and consumed by + * `style.css`; the rest feed the positioning math in this module. + */ +export interface DockLayout { + /** Height of the floating dock bar, in px. */ + barHeight: number + /** Minimum width of the bar before its content grows it, in px. */ + barMinWidth: number + /** Side length of the collapsed (minimized) nub, in px. */ + minimizedSize: number + + /** Diameter of the ambient glow behind the bar, in px. */ + glowSize: number + /** Blur radius applied to the glow, in px. */ + glowBlur: number + + /** + * Maximum number of dock items shown inline on the bar. Any beyond this + * capacity are folded into the overflow button. + */ + maxVisibleItems: number + + /** + * Gap between the bar (and its panel) and the viewport edge, in px. Added on + * top of the device safe-area insets. + */ + viewportMargin: number + + /** + * How much of the dock bar's thickness overlaps the panel/iframe, as a + * fraction of that thickness. `0.5` floats the dock's inner half over the + * panel (its outer half hangs past the panel edge); lower it toward `0` to + * slide the panel clear of the bar so the content underneath stays readable, + * or raise it toward `1` to tuck the panel further behind the bar. + */ + panelOverlapFactor: number + + /** + * Snap-to-edge zone width, as a viewport percentage. While dragging, a + * position within this distance of an edge snaps flush to `0` / `100`. + */ + edgeSnapPercent: number + /** + * Snap-to-center zone half-width, as a viewport percentage. A position within + * this distance of the midpoint snaps to `50`. + */ + centerSnapPercent: number + /** + * Height of the top/bottom detection zones (in px) used when deciding which + * edge a dragged bar should dock to. A larger value widens the "top" and + * "bottom" wedges at the expense of "left"/"right". + */ + edgeZoneHeight: number +} + +/** Which viewport edge the floating bar is docked to. */ +export type DockEdge = 'top' | 'right' | 'bottom' | 'left' + +/** Per-side spacing, in px (viewport margins, safe-area insets, ...). */ +export interface DockMargins { + left: number + top: number + right: number + bottom: number +} + +/** The built-in dock layout. Override individual fields via {@link resolveDockLayout}. */ +export const DEFAULT_DOCK_LAYOUT: DockLayout = Object.freeze({ + barHeight: 34, + barMinWidth: 100, + minimizedSize: 22, + glowSize: 160, + glowBlur: 60, + maxVisibleItems: 5, + viewportMargin: 2, + panelOverlapFactor: -0.04, + edgeSnapPercent: 5, + centerSnapPercent: 2, + edgeZoneHeight: 70, +}) + +/** Merge partial overrides over {@link DEFAULT_DOCK_LAYOUT}. */ +export function resolveDockLayout(overrides?: Partial): DockLayout { + if (!overrides) + return DEFAULT_DOCK_LAYOUT + return { ...DEFAULT_DOCK_LAYOUT, ...overrides } +} + +/** + * Project a layout's dimension fields to the CSS custom properties consumed by + * `style.css`. Bind the result on the `#devframes-anchor` root so the + * values cascade to the bar, minimized nub and glow. + */ +export function dockLayoutCssVars(layout: DockLayout): Record { + return { + '--devframes-dock-height': `${layout.barHeight}px`, + '--devframes-dock-min-width': `${layout.barMinWidth}px`, + '--devframes-dock-minimized-size': `${layout.minimizedSize}px`, + '--devframes-dock-glow-size': `${layout.glowSize}px`, + '--devframes-dock-glow-blur': `${layout.glowBlur}px`, + } +} + +/** + * Add the viewport margin on top of the device safe-area insets, yielding the + * effective per-side spacing the bar and its panel keep from the viewport edge. + */ +export function resolveViewportMargins(safeArea: DockMargins, layout: DockLayout): DockMargins { + return { + left: safeArea.left + layout.viewportMargin, + top: safeArea.top + layout.viewportMargin, + right: safeArea.right + layout.viewportMargin, + bottom: safeArea.bottom + layout.viewportMargin, + } +} + +/** + * Snap a `0`–`100` viewport percentage toward the nearest edge (`0` / `100`) or + * the center (`50`) when it falls inside the configured snap zones. + */ +export function snapDockPercent(value: number, layout: DockLayout): number { + if (value < layout.edgeSnapPercent) + return 0 + if (value > 100 - layout.edgeSnapPercent) + return 100 + if (Math.abs(value - 50) < layout.centerSnapPercent) + return 50 + return value +} + +/** + * Decide which viewport edge a dragged point belongs to, by comparing the angle + * from the viewport center against the four corner angles (offset inward by + * {@link DockLayout.edgeZoneHeight}). + */ +export function resolveDockEdge(params: { + x: number + y: number + viewportWidth: number + viewportHeight: number + layout: DockLayout +}): DockEdge { + const { x, y, viewportWidth, viewportHeight, layout } = params + const centerX = viewportWidth / 2 + const centerY = viewportHeight / 2 + const zone = layout.edgeZoneHeight + + const deg = Math.atan2(y - centerY, x - centerX) + const tl = Math.atan2(0 - centerY + zone, 0 - centerX) + const tr = Math.atan2(0 - centerY + zone, viewportWidth - centerX) + const bl = Math.atan2(viewportHeight - zone - centerY, 0 - centerX) + const br = Math.atan2(viewportHeight - zone - centerY, viewportWidth - centerX) + + if (deg >= tl && deg <= tr) + return 'top' + if (deg >= tr && deg <= br) + return 'right' + if (deg >= br && deg <= bl) + return 'bottom' + return 'left' +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +/** + * Resolve the pixel anchor (the center point of the bar) for a given edge. + * + * The axis pinned to the edge is offset by half the bar's cross-size plus the + * viewport margin; the free axis is driven by the stored percentage and clamped + * so the bar stays fully inside the viewport margins. + */ +export function resolveDockAnchor(params: { + edge: DockEdge + leftPercent: number + topPercent: number + viewportWidth: number + viewportHeight: number + dockWidth: number + dockHeight: number + margins: DockMargins +}): { left: number, top: number } { + const { + edge, + leftPercent, + topPercent, + viewportWidth, + viewportHeight, + dockWidth, + dockHeight, + margins, + } = params + + const halfWidth = dockWidth / 2 + const halfHeight = dockHeight / 2 + + const left = leftPercent * viewportWidth / 100 + const top = topPercent * viewportHeight / 100 + + switch (edge) { + case 'top': + return { + left: clamp(left, halfWidth + margins.left, viewportWidth - halfWidth - margins.right), + top: margins.top + halfHeight, + } + case 'right': + return { + left: viewportWidth - margins.right - halfHeight, + top: clamp(top, halfWidth + margins.top, viewportHeight - halfWidth - margins.bottom), + } + case 'left': + return { + left: margins.left + halfHeight, + top: clamp(top, halfWidth + margins.top, viewportHeight - halfWidth - margins.bottom), + } + case 'bottom': + default: + return { + left: clamp(left, halfWidth + margins.left, viewportWidth - halfWidth - margins.right), + top: viewportHeight - margins.bottom - halfHeight, + } + } +} diff --git a/packages/hub-ui/src/client/components/floating/FloatingElements.vue b/packages/hub-ui/src/client/components/floating/FloatingElements.vue new file mode 100644 index 00000000..a0c9d1b7 --- /dev/null +++ b/packages/hub-ui/src/client/components/floating/FloatingElements.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts b/packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts new file mode 100644 index 00000000..e770fce1 --- /dev/null +++ b/packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts @@ -0,0 +1,130 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { computed, defineComponent, h, onMounted, ref, shallowRef } from 'vue' +import FloatingPopover from './FloatingPopover' + +// @unocss-include + +/** + * A story harness: render an anchor element, then feed its DOM node to the + * popover on mount (the real component positions against a live element). + */ +function harness(content: string | (() => any), anchorLabel = 'Anchor') { + return defineComponent({ + setup() { + const anchor = ref(null) + const item = shallowRef(null) + onMounted(() => { + if (anchor.value) + item.value = { el: anchor.value, content } + }) + return () => h('div', { class: 'flex items-center justify-center p20 min-h-80 font-sans' }, [ + h('button', { + ref: (el: any) => (anchor.value = el), + class: 'px3 py1.5 rounded border border-base bg-glass color-base shadow', + }, anchorLabel), + h(FloatingPopover, { item: item.value, dismissOnClickOutside: false }), + ]) + }, + }) +} + +const meta = { + title: 'Dock/Floating/Popover', + component: FloatingPopover, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'The floating primitive behind tooltips, group popovers, the overflow panel and the edge-position dropdown. It anchors to a DOM element and auto-aligns based on the anchor\'s position in the viewport.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** String content — the tooltip form. */ +export const TextTooltip: Story = { + render: () => harness('Open the inspector'), +} + +/** VNode content — a small menu, as the group/overflow popovers render. */ +export const MenuContent: Story = { + render: () => harness(() => h('div', { class: 'flex flex-col gap-0.5 min-w-40' }, [ + h('div', { class: 'px2 pt1 pb1.5 op60 text-2.75 uppercase tracking-wide font-medium' }, 'Menu'), + ...['Overview', 'Pages', 'Components'].map(label => + h('button', { class: 'px2 py1.5 rounded text-sm text-left op80 hover:op100 hover:bg-active transition' }, label)), + ]), 'Reveal menu'), +} + +/** + * A real toggle button drives the popover (rather than the mount-time + * harness the other stories use), to exercise `ignore` — clicking the + * trigger again while open must close it once, not close-then-reopen — and + * `panelClass`, which replaces the default tooltip padding. + */ +export const ToggleTrigger: Story = { + render: () => defineComponent({ + setup() { + const triggerEl = ref(null) + const open = ref(false) + const item = computed(() => (open.value && triggerEl.value) + ? { el: triggerEl.value, content: () => h('div', { class: 'flex flex-col gap-0.5 min-w-40' }, [ + h('div', { class: 'px2 pt1 pb1.5 op60 text-2.75 uppercase tracking-wide font-medium' }, 'Menu'), + ...['Overview', 'Pages', 'Components'].map(label => + h('button', { class: 'px2 py1.5 rounded text-sm text-left op80 hover:op100 hover:bg-active transition' }, label)), + ]) } + : null) + return () => h('div', { class: 'flex items-center justify-center p20 min-h-80 font-sans' }, [ + h('button', { + ref: (el: any) => (triggerEl.value = el), + class: 'px3 py1.5 rounded border border-base bg-glass color-base shadow', + onClick: () => (open.value = !open.value), + }, 'Toggle menu'), + h(FloatingPopover, { + item: item.value, + panelClass: '!p0', + ignore: [triggerEl], + onDismiss: () => (open.value = false), + }), + ]) + }, + }), +} + +export const CornerAnchors: Story = { + render: () => defineComponent({ + setup() { + const corners = [ + { label: 'Top left', class: 'left-2 top-2' }, + { label: 'Top right', class: 'right-2 top-2' }, + { label: 'Bottom left', class: 'left-2 bottom-2' }, + { label: 'Bottom right', class: 'right-2 bottom-2' }, + ].map(corner => ({ + ...corner, + el: null as HTMLElement | null, + item: shallowRef(null), + })) + const menu = () => h('div', { class: 'flex flex-col gap-0.5 min-w-40' }, [ + h('div', { class: 'px2 pt1 pb1.5 op60 text-2.75 uppercase tracking-wide font-medium' }, 'Menu'), + ...['Overview', 'Pages', 'Components'].map(label => + h('button', { class: 'px2 py1.5 rounded text-sm text-left op80 hover:op100 hover:bg-active transition' }, label)), + ]) + onMounted(() => { + for (const corner of corners) { + if (corner.el) + corner.item.value = { el: corner.el, content: menu } + } + }) + return () => h('div', { class: 'min-h-100 font-sans' }, corners.flatMap(corner => [ + h('button', { + key: corner.label, + ref: (el: any) => (corner.el = el), + class: `fixed ${corner.class} px3 py1.5 rounded border border-base bg-glass color-base shadow`, + }, corner.label), + h(FloatingPopover, { item: corner.item.value, dismissOnClickOutside: false }), + ])) + }, + }), +} diff --git a/packages/hub-ui/src/client/components/floating/FloatingPopover.ts b/packages/hub-ui/src/client/components/floating/FloatingPopover.ts new file mode 100644 index 00000000..5c1b63a2 --- /dev/null +++ b/packages/hub-ui/src/client/components/floating/FloatingPopover.ts @@ -0,0 +1,177 @@ +import type { MaybeElementRef } from '@vueuse/core' +import type { PropType, VNode } from 'vue' +import type { FloatingPopoverProps } from '../../state/floating-tooltip' +import { onClickOutside, useDebounceFn, useEventListener } from '@vueuse/core' +import { defineComponent, h, nextTick, onMounted, onUpdated, reactive, ref, useTemplateRef, watch } from 'vue' +import { resolveFloatingPosition } from './floating-position' + +// @unocss-include + +const FloatingPopoverComponent = defineComponent({ + name: 'FloatingPopover', + props: { + item: { + type: Object as PropType, + required: false, + }, + dismissOnClickOutside: { + type: Boolean, + default: true, + }, + /** Appended to the panel's class list — lets a consumer replace the default tooltip padding (e.g. a listbox). */ + panelClass: { + type: [String, Array] as PropType, + required: false, + }, + /** Elements `dismissOnClickOutside` should not treat as "outside" — typically the trigger that toggles this popover. */ + ignore: { + type: Array as PropType, + required: false, + }, + }, + emits: ['dismiss'], + setup(props, { emit }) { + const panel = useTemplateRef('panel') + const el = ref(props.item?.el) + const renderCounter = ref(0) + + const panelSize = reactive({ width: 0, height: 0 }) + // Before the first measurement, `resolveFloatingPosition` centers the panel + // under the anchor via `transform: translateX(-50%)` (it doesn't know the + // panel's real width yet); once measured, it switches to an absolute `left` + // with no transform. Both resolve to the same visual position, but + // transitioning `left` and `transform` independently between them produces + // a visible sideways wobble — so `measured` only flips (re-enabling the + // transition) a tick after `panelSize` updates, letting that one + // size-correcting render apply instantly rather than animate. + const measured = ref(false) + + function measurePanel() { + if (!props.item || !panel.value) + return + const { width, height } = panel.value.getBoundingClientRect() + if (Math.abs(width - panelSize.width) > 0.5 || Math.abs(height - panelSize.height) > 0.5) { + panelSize.width = width + panelSize.height = height + } + nextTick(() => { + measured.value = true + }) + } + + onMounted(measurePanel) + onUpdated(measurePanel) + + useEventListener(window, 'resize', () => { + if (el.value) + renderCounter.value++ + }) + + // The panel is `position: fixed` against a rect measured at render time, so + // scrolling any ancestor (not just the window) needs to trigger a re-measure. + useEventListener(window, 'scroll', () => { + if (el.value) + renderCounter.value++ + }, { capture: true, passive: true }) + + const clearThrottled = useDebounceFn(() => { + if (props.item?.el == null) { + el.value = undefined + panelSize.width = 0 + panelSize.height = 0 + measured.value = false + } + }, 800) + + if (props.dismissOnClickOutside) { + onClickOutside(panel, () => { + emit('dismiss') + }, { ignore: props.ignore }) + } + + watch( + () => props.item, + (value) => { + if (value) { + if (el.value !== value.el) + el.value = value.el + else + renderCounter.value++ + } + else { + clearThrottled() + } + }, + ) + + let previousContent: VNode | undefined + let previousStyle: Record = {} + + return () => { + // Force re-render to update the position + // eslint-disable-next-line ts/no-unused-expressions + renderCounter.value + + if (!el.value) + return null + + const transitionClass = measured.value ? 'transition-all duration-300' : 'transition-opacity duration-300' + + // When dismissing (item is null), keep the last known position + // so the popover fades out in place instead of jumping + if (!props.item) { + return h( + 'div', + { + ref: 'panel', + class: [ + `fixed z-floating-tooltip text-xs ${transitionClass} w-max bg-glass:80 color-base border border-base rounded px2 p1`, + 'op0 pointer-events-none', + props.panelClass, + ], + style: previousStyle, + }, + previousContent, + ) + } + + const rect = el.value.getBoundingClientRect() + + const { style } = resolveFloatingPosition({ + rect, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + panelWidth: panelSize.width, + panelHeight: panelSize.height, + gap: props.item.gap, + placement: props.item.placement, + }) + + previousStyle = style + + const content = ( + typeof props.item?.content === 'string' + ? h('span', props.item?.content) + : props.item?.content() + ) ?? previousContent + + previousContent = content + + return h( + 'div', + { + ref: 'panel', + class: [ + `fixed z-floating-tooltip text-xs ${transitionClass} w-max bg-glass:80 color-base border border-base rounded px2 p1`, + props.item ? 'op100' : 'op0 pointer-events-none', + props.panelClass, + ], + style, + }, + content, + ) + } + }, +}) + +export default FloatingPopoverComponent diff --git a/packages/hub-ui/src/client/components/floating/floating-position.ts b/packages/hub-ui/src/client/components/floating/floating-position.ts new file mode 100644 index 00000000..9fad0329 --- /dev/null +++ b/packages/hub-ui/src/client/components/floating/floating-position.ts @@ -0,0 +1,102 @@ +const DETECT_MARGIN = 100 +const DEFAULT_GAP = 10 +const VIEWPORT_MARGIN = 8 + +type FloatingAlign = 'top' | 'bottom' | 'left' | 'right' + +interface FloatingAnchorRect { + left: number + top: number + width: number + height: number +} + +export interface ResolveFloatingPositionOptions { + rect: FloatingAnchorRect + viewportWidth: number + viewportHeight: number + panelWidth?: number + panelHeight?: number + gap?: number + placement?: FloatingAlign +} + +export interface ResolvedFloatingPosition { + align: FloatingAlign + style: Record +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), Math.max(max, min)) +} + +export function resolveFloatingPosition(options: ResolveFloatingPositionOptions): ResolvedFloatingPosition { + const { + rect, + viewportWidth: vw, + viewportHeight: vh, + panelWidth = 0, + panelHeight = 0, + gap = DEFAULT_GAP, + placement, + } = options + + const anchorRight = rect.left + rect.width + const anchorBottom = rect.top + rect.height + + let align: FloatingAlign = 'bottom' + if (placement) + align = placement + else if (rect.left < DETECT_MARGIN) + align = 'right' + else if (anchorRight > vw - DETECT_MARGIN) + align = 'left' + else if (rect.top < DETECT_MARGIN) + align = 'bottom' + else if (anchorBottom > vh - DETECT_MARGIN) + align = 'top' + + if (!placement && panelWidth && panelHeight) { + if (align === 'bottom' && anchorBottom + gap + panelHeight > vh - VIEWPORT_MARGIN && rect.top - gap - panelHeight >= VIEWPORT_MARGIN) + align = 'top' + else if (align === 'top' && rect.top - gap - panelHeight < VIEWPORT_MARGIN && anchorBottom + gap + panelHeight <= vh - VIEWPORT_MARGIN) + align = 'bottom' + else if (align === 'right' && anchorRight + gap + panelWidth > vw - VIEWPORT_MARGIN && rect.left - gap - panelWidth >= VIEWPORT_MARGIN) + align = 'left' + else if (align === 'left' && rect.left - gap - panelWidth < VIEWPORT_MARGIN && anchorRight + gap + panelWidth <= vw - VIEWPORT_MARGIN) + align = 'right' + } + + const style: Record = {} + + if (align === 'top' || align === 'bottom') { + if (align === 'bottom') + style.top = `${anchorBottom + gap}px` + else + style.bottom = `${vh - rect.top + gap}px` + + if (panelWidth) { + style.left = `${clamp(rect.left + rect.width / 2 - panelWidth / 2, VIEWPORT_MARGIN, vw - panelWidth - VIEWPORT_MARGIN)}px` + } + else { + style.left = `${rect.left + rect.width / 2}px` + style.transform = 'translateX(-50%)' + } + } + else { + if (align === 'right') + style.left = `${anchorRight + gap}px` + else + style.right = `${vw - rect.left + gap}px` + + if (panelHeight) { + style.top = `${clamp(rect.top + rect.height / 2 - panelHeight / 2, VIEWPORT_MARGIN, vh - panelHeight - VIEWPORT_MARGIN)}px` + } + else { + style.top = `${rect.top + rect.height / 2}px` + style.transform = 'translateY(-50%)' + } + } + + return { align, style } +} diff --git a/packages/hub-ui/src/client/components/icons/BracketLeft.vue b/packages/hub-ui/src/client/components/icons/BracketLeft.vue new file mode 100644 index 00000000..99a62977 --- /dev/null +++ b/packages/hub-ui/src/client/components/icons/BracketLeft.vue @@ -0,0 +1,8 @@ + diff --git a/packages/hub-ui/src/client/components/icons/BracketRight.vue b/packages/hub-ui/src/client/components/icons/BracketRight.vue new file mode 100644 index 00000000..9b65059b --- /dev/null +++ b/packages/hub-ui/src/client/components/icons/BracketRight.vue @@ -0,0 +1,8 @@ + diff --git a/packages/hub-ui/src/client/components/icons/Brand.stories.ts b/packages/hub-ui/src/client/components/icons/Brand.stories.ts new file mode 100644 index 00000000..8364658f --- /dev/null +++ b/packages/hub-ui/src/client/components/icons/Brand.stories.ts @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import BrandMark from './BrandMark.vue' +import BrandWordmark from './BrandWordmark.vue' + +const meta = { + title: 'Brand/Logos', + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'The Devframes brand marks used across the dock and standalone shells.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** The Devframes mark (the minimized dock nub / auth screen logo). */ +export const Mark: Story = { + name: 'BrandMark', + render: () => ({ setup: () => () => h('div', { class: 'w-24 h-24' }, h(BrandMark)) }), +} + +/** The Devframes wordmark (recolors with the theme). */ +export const Wordmark: Story = { + name: 'BrandWordmark', + render: () => ({ setup: () => () => h(BrandWordmark) }), +} diff --git a/packages/hub-ui/src/client/components/icons/BrandMark.vue b/packages/hub-ui/src/client/components/icons/BrandMark.vue new file mode 100644 index 00000000..e50d5fd5 --- /dev/null +++ b/packages/hub-ui/src/client/components/icons/BrandMark.vue @@ -0,0 +1,7 @@ + diff --git a/packages/hub-ui/src/client/components/icons/BrandWordmark.vue b/packages/hub-ui/src/client/components/icons/BrandWordmark.vue new file mode 100644 index 00000000..0b86ea20 --- /dev/null +++ b/packages/hub-ui/src/client/components/icons/BrandWordmark.vue @@ -0,0 +1,8 @@ + diff --git a/packages/hub-ui/src/client/components/icons/IconifyIcon.vue b/packages/hub-ui/src/client/components/icons/IconifyIcon.vue new file mode 100644 index 00000000..7a210cca --- /dev/null +++ b/packages/hub-ui/src/client/components/icons/IconifyIcon.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/hub-ui/src/client/components/message/MessageItem.stories.ts b/packages/hub-ui/src/client/components/message/MessageItem.stories.ts new file mode 100644 index 00000000..3504b460 --- /dev/null +++ b/packages/hub-ui/src/client/components/message/MessageItem.stories.ts @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { message, sampleMessages } from '../../stories/fixtures' +import MessageItem from './MessageItem.vue' + +const meta = { + title: 'Messages/MessageItem', + component: MessageItem, + tags: ['autodocs'], + decorators: [() => ({ template: '
' })], + parameters: { + docs: { + description: { + component: 'A single log/message row: level accent, icon, title, description, relative time and category/label chips.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Error: Story = { + render: () => ({ setup: () => () => h(MessageItem, { entry: sampleMessages[0]! }) }), +} + +export const Loading: Story = { + render: () => ({ setup: () => () => h(MessageItem, { entry: message({ level: 'warn', message: 'Running checks…', status: 'loading', category: 'a11y' }) }) }), +} + +/** The compact variant used inside toasts (no timestamp / chips). */ +export const Compact: Story = { + render: () => ({ setup: () => () => h(MessageItem, { entry: sampleMessages[2]!, compact: true }) }), +} + +/** Every level stacked together. */ +export const AllLevels: Story = { + render: () => ({ + setup: () => () => h('div', { class: 'flex flex-col gap-3' }, sampleMessages.map(entry => h(MessageItem, { key: entry.id, entry }))), + }), +} diff --git a/packages/hub-ui/src/client/components/message/MessageItem.vue b/packages/hub-ui/src/client/components/message/MessageItem.vue new file mode 100644 index 00000000..2704d240 --- /dev/null +++ b/packages/hub-ui/src/client/components/message/MessageItem.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/hub-ui/src/client/components/message/MessageItemConstants.ts b/packages/hub-ui/src/client/components/message/MessageItemConstants.ts new file mode 100644 index 00000000..ba5aa4e5 --- /dev/null +++ b/packages/hub-ui/src/client/components/message/MessageItemConstants.ts @@ -0,0 +1,29 @@ +import type { DevframeMessageLevel } from '@devframes/hub' + +// @unocss-include + +export interface LevelStyle { + icon: string + color: string + bg: string + label: string +} + +export const levels: Record = { + info: { icon: 'i-ph:info-duotone', color: 'text-blue', bg: 'bg-blue', label: 'Info' }, + warn: { icon: 'i-ph:warning-duotone', color: 'text-amber', bg: 'bg-amber', label: 'Warning' }, + error: { icon: 'i-ph:x-circle-duotone', color: 'text-red', bg: 'bg-red', label: 'Error' }, + success: { icon: 'i-ph:check-circle-duotone', color: 'text-green', bg: 'bg-green', label: 'Success' }, + debug: { icon: 'i-ph:bug-duotone', color: 'text-gray', bg: 'bg-gray', label: 'Debug' }, +} + +// Intentionally uses fixed saturation/lightness (unlike @vitejs/devtools-ui/utils/color which +// is dark-mode-aware via Vue reactivity). Webcomponents run in shadow DOM with media-based dark +// mode, so they can't access the isDark composable. +export function getHashColorFromString(name: string, opacity: number = 1): string { + let hash = 0 + for (let i = 0; i < name.length; i++) + hash = name.charCodeAt(i) + ((hash << 5) - hash) + const h = hash % 360 + return `hsla(${h}, 55%, 55%, ${opacity})` +} diff --git a/packages/hub-ui/src/client/components/views-builtin/SettingsAdvanced.vue b/packages/hub-ui/src/client/components/views-builtin/SettingsAdvanced.vue new file mode 100644 index 00000000..50193373 --- /dev/null +++ b/packages/hub-ui/src/client/components/views-builtin/SettingsAdvanced.vue @@ -0,0 +1,169 @@ + + + diff --git a/packages/hub-ui/src/client/components/views-builtin/SettingsAppearance.vue b/packages/hub-ui/src/client/components/views-builtin/SettingsAppearance.vue new file mode 100644 index 00000000..0975d8c4 --- /dev/null +++ b/packages/hub-ui/src/client/components/views-builtin/SettingsAppearance.vue @@ -0,0 +1,131 @@ + + + diff --git a/packages/hub-ui/src/client/components/views-builtin/SettingsDocks.vue b/packages/hub-ui/src/client/components/views-builtin/SettingsDocks.vue new file mode 100644 index 00000000..ea29142e --- /dev/null +++ b/packages/hub-ui/src/client/components/views-builtin/SettingsDocks.vue @@ -0,0 +1,557 @@ + + + diff --git a/packages/hub-ui/src/client/components/views-builtin/SettingsShortcuts.vue b/packages/hub-ui/src/client/components/views-builtin/SettingsShortcuts.vue new file mode 100644 index 00000000..fad03431 --- /dev/null +++ b/packages/hub-ui/src/client/components/views-builtin/SettingsShortcuts.vue @@ -0,0 +1,434 @@ + + + diff --git a/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.stories.ts b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.stories.ts new file mode 100644 index 00000000..dddb5b14 --- /dev/null +++ b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.stories.ts @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { mountWithContext } from '../../stories/story-helpers' +import ViewBuiltinClientAuthNotice from './ViewBuiltinClientAuthNotice.vue' + +const meta = { + title: 'Views/Builtin/AuthNotice', + component: ViewBuiltinClientAuthNotice, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'The authorization prompt shown when the client is not yet trusted — explains the risk and takes a one-time code.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + setup: () => mountWithContext( + { isTrusted: false }, + ctx => h('div', { class: 'h-140 bg-base color-base border border-base rounded-lg overflow-auto' }, h(ViewBuiltinClientAuthNotice, { context: ctx })), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.vue b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.vue new file mode 100644 index 00000000..26ac0e8a --- /dev/null +++ b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.vue @@ -0,0 +1,133 @@ + + + diff --git a/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinSettings.stories.ts b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinSettings.stories.ts new file mode 100644 index 00000000..350575fb --- /dev/null +++ b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinSettings.stories.ts @@ -0,0 +1,53 @@ +import type { DevframeViewBuiltin } from '@devframes/hub' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { groupedEntries } from '../../stories/fixtures' +import { mountWithContext } from '../../stories/story-helpers' +import ViewBuiltinSettings from './ViewBuiltinSettings.vue' + +const entry: DevframeViewBuiltin = { + type: '~builtin', + id: '~settings', + title: 'Settings', + icon: 'ph:gear-duotone', +} + +function stage(children: any) { + return h('div', { class: 'h-140 bg-base color-base border border-base rounded-lg overflow-hidden font-sans' }, children) +} + +const meta = { + title: 'Views/Builtin/Settings', + component: ViewBuiltinSettings, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'The built-in settings view: Appearance, Docks, Shortcuts and Advanced tabs. Click the tabs to switch.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Embedded client — the Appearance tab shows dock-mode options. */ +export const Embedded: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries, clientType: 'embedded' }, + ctx => stage(h(ViewBuiltinSettings, { context: ctx, entry })), + ), + }), +} + +/** Standalone client — dock-mode options are hidden. */ +export const Standalone: Story = { + render: () => ({ + setup: () => mountWithContext( + { entries: groupedEntries, clientType: 'standalone' }, + ctx => stage(h(ViewBuiltinSettings, { context: ctx, entry })), + ), + }), +} diff --git a/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinSettings.vue b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinSettings.vue new file mode 100644 index 00000000..6570522a --- /dev/null +++ b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinSettings.vue @@ -0,0 +1,75 @@ + + + diff --git a/packages/hub-ui/src/client/components/views/ViewCustomRenderer.vue b/packages/hub-ui/src/client/components/views/ViewCustomRenderer.vue new file mode 100644 index 00000000..9f91ad20 --- /dev/null +++ b/packages/hub-ui/src/client/components/views/ViewCustomRenderer.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/hub-ui/src/client/components/views/ViewEntry.vue b/packages/hub-ui/src/client/components/views/ViewEntry.vue new file mode 100644 index 00000000..200d94ad --- /dev/null +++ b/packages/hub-ui/src/client/components/views/ViewEntry.vue @@ -0,0 +1,80 @@ + + +