From c590674242606ec2b587d3e6640186007c874d4c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 5 Aug 2026 23:56:42 +0000 Subject: [PATCH 1/7] docs: plan for /__devframes/ standard middleware handlers --- plans/devframes-standard-middleware.md | 183 +++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 plans/devframes-standard-middleware.md diff --git a/plans/devframes-standard-middleware.md b/plans/devframes-standard-middleware.md new file mode 100644 index 00000000..58cb402f --- /dev/null +++ b/plans/devframes-standard-middleware.md @@ -0,0 +1,183 @@ +# Plan: `/__devframes/` framework-agnostic standard middleware + +> Plan of record settled in a design interview on 2026-08-05. Implementation lands as a +> 5-PR GitHub stack (bottom → top), each layer passing the full gauntlet +> (`pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build`). + +## Goal + +One web-standard handler (`(Request) => Response`, Comark-style — see +) that carries the +entire devtools surface — mounted devframes, WS RPC, auth, MCP, embedded floating mode — +mountable on any framework with a single catch-all route (Vite, Nitro, Hono, Next.js, Nuxt, +SvelteKit), running on Node ≥ 20 and Bun. The hub stays headless; UI is a composable slot. + +## Architecture + +### Two `createHandler` factories, one UI slot + +| Import | Serves | Default base | +|---|---|---| +| `devframe/handler` | **One devframe**: its SPA (`distDir`; omitted → bridge mode serving only meta + WS), `__connection.json`, `__mcp`, WS RPC, auth. Own isolated context, one `def.setup(ctx)`. | `/__/` (hosted rule) | +| `@devframes/hub/handler` | **Multi-frame, headless**: shared hub context (docks/terminals/messages/commands + hub built-in RPCs + shared-state slots); every frame's `setup(ctx)` runs against it → one merged RPC registry, one WS endpoint, **one hub Auth**, one aggregate MCP. Frames auto-registered as iframe docks. | `/__devframes/` | + +**`DevframeHubUi` slot** (type lives in `@devframes/hub`; data-first, zero policy): + +```ts +interface DevframeHubUi { + viewer?: { distDir: string } // standalone viewer SPA served at the namespace root + embedded?: { entry: string } // prebuilt bootstrap served at embedded.js +} +``` + +`@devframes/hub-ui` (new package) exports `createUi(options?)` — the *reference* implementation +(a port of Vite DevTools' web components). Vite DevTools / community supply their own `ui` +object to the same slot, reusing all infra. There is **no** `createHandler` in hub-ui. + +### Handler API (both factories) + +```ts +const h = createHandler(defOrOptions, { + base?, // mount base; hosted default /__/ (core) or /__devframes/ (hub) + server?, // sugar: node http server → shared WS upgrade at __ws + ws?: DevframeWsOptions, // explicit control — url > port > route (default '__ws') + auth?, // default TRUE (existing OTP/token machinery); explicit false to opt out + mcp?, // per-frame MCP (core) / aggregate MCP (hub) + key?, // globalThis memoization — HMR re-evaluation returns the live instance + origin?, // banner origin override; else derived lazily from first request + // hub only: + devframes?, context?, // declarative list OR pre-built hub context + configure?, // async (ctx) => {} for docks/commands/terminals/messages registration + ui?, // DevframeHubUi +}) +// → { fetch(request, runtimeCtx?), nodeMiddleware, websocket, ready, context, +// connectionMeta(), close() } +``` + +- Sync factory, **eager** async init; `fetch` awaits `ready` internally. +- `fetch` 404s inside its base; `nodeMiddleware` (connect-style) calls `next()` outside it. +- Bun: `fetch(req, server)` second arg + exposed `websocket` hooks (crossws Bun adapter). +- `key` memoization: a re-evaluation returns the live instance (closes/replaces it if the + options changed) — prevents eager side-car leaks under Next/Nitro/SvelteKit dev HMR. + +### WebSocket resolution (precedence) + +1. `ws.url` — advertise an external endpoint verbatim; the handler owns **no** transport. + Hosts that want the handler's RPC on their *own* WS server use the documented recipe: + `attachWsRpcTransport(handler.context RPC group, { server, path })` + a matching `ws`. +2. `ws.port` — explicit side-car port. +3. `server` — shared upgrade on the host's node http server at ``. +4. *(default)* — **eager** auto side-car on a free port, started at handler creation so + `__connection.json` is stable from the first request. + +All four advertised consistently in `__connection.json`. The WS route unifies on **`__ws`** +everywhere (breaking: was `__devframe_ws`), matching upstream Vite DevTools' `/__devtools/__ws`. + +### Path layout (hub, under base `/__devframes/`) + +| Path | Serves | Condition | +|---|---|---| +| `/` | `ui.viewer` dist, else the index document | — | +| `__index.json` | JSON index: frame ids/bases, endpoint paths | always | +| `embedded.js` | `ui.embedded.entry` | 404 without `ui.embedded` | +| `__connection.json` | hub connection meta | always | +| `__ws` | WS upgrade route (shared-server tier) | always | +| `__client-imports.js` | dock client-script import map | always | +| `__mcp` | **aggregate** MCP over the shared context registry | when `mcp` enabled | +| `/` | each frame's SPA + its per-frame `__connection.json` | reserved-name-validated ids | + +Per-frame `__mcp` exists only on the singular handler (the hub's shared context makes the +aggregate the meaningful endpoint; tool ids are already namespaced `devframes:plugin::*`). + +### Auth + +- Gated **by default** on both factories (existing `createInteractiveAuth` OTP + token + machinery; `anonymous:` pre-trust prefix; WS origin gate). +- **Hub: a single Auth.** One `DevframeAuthHandler` owned by the hub handler, one OTP + handshake, one trusted-token store, enforced at the one shared transport. Mounted frames + have no auth of their own — trust established once covers every frame, the aggregate MCP + origin gate, and the hub built-ins. Iframes may arrive pre-authorized via hub-served + `authToken` meta or reuse the parent page's connection (`__DEVFRAME_CONNECTION__`). +- Banner origin derived lazily from the first request (`origin` option overrides). + +### Embedded mode + +- `embedded.js` = prebuilt bundle: headless `createDevframeClientHost` + hub-ui's + `DockEmbedded`. **Always visible on load** — no view-mode model in hub-ui. Visibility + policy belongs to whoever authors the entry (Vite DevTools keeps its normal/passive/hidden + model in *its own* entry via its own `embedded: { entry }`). Dock-local state + (position/collapse) stays — component behavior, not visibility policy. +- Base discovery from `import.meta.url`; OTP/auth UI included. +- Injection = documented one-line ` + + 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 @@ + + +