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 @@ + + +