resizingState = { bottom: true, left: true }"
+ @touchstart.passive="() => resizingState = { bottom: true, left: true }"
+ />
+
resizingState = { bottom: true, right: true }"
+ @touchstart.passive="() => resizingState = { bottom: true, right: true }"
+ />
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
switchEntry(e?.id)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ setDocksOverflowPanel(null)" />
+ setDocksSidebarOverflowPanel(null)" />
+ setDocksGroupPanel(null)" />
+ setDockContextMenu(null)" />
+ setEdgePositionDropdown(null)" />
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ entry.message }}
+
+
{{ timeAgo }}
+
+
+
+ {{ entry.description }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reset Keyboard Shortcuts
+
+
+ Remove all custom shortcut overrides and restore default keybindings
+
+
+
+
+
+
+
+
+
+ Reset Dock Settings
+
+
+ Restore default dock visibility, order, and pinning
+
+
+
+
+
+
+
+
+
+
+ Reset All Settings
+
+
+ Reset everything to defaults including appearance, docks, and shortcuts
+
+
+
+
+
+
+
+
+
+
+
+ Revoke Authorization
+
+
+ De-authorize this browser and revoke its access token; you'll re-authorize with a new code
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ Color mode
+ Theme for Devframes and its inner panels
+
+
+
+
+
+
+
+
+
+ Dock mode
+ How the Devframes panel is displayed
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ Manage visibility and order of dock entries. Hidden entries will not appear in the dock bar.
+
+
+
+
+
+
+
+
+
{{ getCategoryLabel(category) }}
+
({{ entries.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dock.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No tools in this group yet
+
+
+
+
+
{{ getCategoryLabel(subcategory) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ member.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ Customize keyboard shortcuts for commands.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.command.title }}
+ server
+
+
+ {{ row.command.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No commands found
+
+
+
+
+
+
+
+
+ Edit Shortcut
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+ Authorize Devframes
+
+
+ Devframes can access your server, read your filesystem, and run commands.
+ Confirm it's you before continuing.
+
+
+
+
+
+
+ Find the
+ 6-digit code
+ printed in your terminal.
+
+
+
+
+
+
+
+ You can also use the magic link to authorize automatically.
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ Unknown builtin entry: {{ entry }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Unknown entry: {{ entry }}
+
+
+
+
+ Loading...
+
+
+
+
diff --git a/packages/hub-ui/src/client/components/views/ViewIframe.vue b/packages/hub-ui/src/client/components/views/ViewIframe.vue
new file mode 100644
index 00000000..1dfba0a4
--- /dev/null
+++ b/packages/hub-ui/src/client/components/views/ViewIframe.vue
@@ -0,0 +1,339 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading iframe...
+
+
+
+
diff --git a/packages/hub-ui/src/client/components/views/ViewJsonRender.vue b/packages/hub-ui/src/client/components/views/ViewJsonRender.vue
new file mode 100644
index 00000000..47755a3f
--- /dev/null
+++ b/packages/hub-ui/src/client/components/views/ViewJsonRender.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+ Loading...
+
+
+ Error loading spec: {{ error }}
+
+
+
+
+
+ No spec provided
+
+
+
diff --git a/packages/hub-ui/src/client/components/views/ViewLauncher.stories.ts b/packages/hub-ui/src/client/components/views/ViewLauncher.stories.ts
new file mode 100644
index 00000000..c90268d9
--- /dev/null
+++ b/packages/hub-ui/src/client/components/views/ViewLauncher.stories.ts
@@ -0,0 +1,71 @@
+import type { DevframeViewLauncher } from '@devframes/hub'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { h } from 'vue'
+import { mountWithContext } from '../../stories/story-helpers'
+import ViewLauncher from './ViewLauncher.vue'
+
+function launcher(
+ status: 'idle' | 'loading' | 'error' | 'success',
+ extras: Partial = {},
+): DevframeViewLauncher {
+ return {
+ id: 'launcher',
+ type: 'launcher',
+ title: 'Launcher',
+ icon: 'ph:rocket-launch-duotone',
+ launcher: {
+ title: 'Launch My Cool App',
+ description: 'Start the dev server and open it here.',
+ status,
+ ...extras,
+ },
+ } as DevframeViewLauncher
+}
+
+function stage(children: any) {
+ return h('div', { class: 'h-100 bg-base color-base border border-base rounded-lg overflow-hidden font-sans' }, children)
+}
+
+const meta = {
+ title: 'Views/Launcher',
+ component: ViewLauncher,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component: 'A launcher view: a call-to-action that runs the entry\'s `onLaunch` and reflects idle / loading / success / error status.',
+ },
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function launcherStory(
+ status: 'idle' | 'loading' | 'error' | 'success',
+ extras: Partial = {},
+): Story {
+ return {
+ render: () => ({
+ setup: () => mountWithContext({}, ctx => stage(h(ViewLauncher, { context: ctx, entry: launcher(status, extras) }))),
+ }),
+ }
+}
+
+export const Idle: Story = launcherStory('idle')
+export const Loading: Story = launcherStory('loading')
+export const Success: Story = launcherStory('success')
+
+// A failed launch surfaces the reason and offers a clickable Retry.
+export const Error: Story = launcherStory('error', {
+ error: 'No test files found, exiting with code 1',
+})
+
+// A launcher tracking a terminal session: it shows the process's progress and
+// offers to jump to that session in the Terminals dock.
+export const WithProgress: Story = launcherStory('loading', {
+ buttonLoading: 'Starting…',
+ terminalSessionId: 'my-app:dev',
+ digest: 'Waiting for the server…',
+})
diff --git a/packages/hub-ui/src/client/components/views/ViewLauncher.vue b/packages/hub-ui/src/client/components/views/ViewLauncher.vue
new file mode 100644
index 00000000..b440e04f
--- /dev/null
+++ b/packages/hub-ui/src/client/components/views/ViewLauncher.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+ {{ entry.launcher.title }}
+
+
{{ entry.launcher.description }}
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+
diff --git a/packages/hub-ui/src/client/constants.ts b/packages/hub-ui/src/client/constants.ts
new file mode 100644
index 00000000..0864513a
--- /dev/null
+++ b/packages/hub-ui/src/client/constants.ts
@@ -0,0 +1,45 @@
+import type { DevframeViewBuiltin } from '@devframes/hub'
+
+/**
+ * `window` event the "Hide" command dispatches to ask whoever mounted the
+ * embedded dock to tear it down for the session. The hub-ui dock is always
+ * visible by design — hiding is a page-lifetime action, and a reload brings
+ * the dock back (see `src/client/embedded/index.ts`).
+ */
+export const HUB_UI_HIDE_EVENT = 'devframes:hub-ui:hide'
+
+/**
+ * Dock id of the Devframe Inspector (mounted from `@devframes/plugin-inspect`).
+ * The client gates the dock behind the `showDevframeInspector` user setting.
+ * Mirrors the plugin's `PLUGIN_ID`.
+ */
+export const INSPECTOR_DOCK_ID = 'devframes_plugin_inspect'
+
+/**
+ * Dock id of the Terminals feed (from `@devframes/plugin-terminals`).
+ * A launcher tracking a terminal session targets this dock via
+ * `hub:docks:activate({ dockId, params: { sessionId } })` to jump the user
+ * straight to that session's output. Mirrors the plugin's `PLUGIN_ID`.
+ */
+export const TERMINALS_DOCK_ID = 'devframes_plugin_terminals'
+
+/**
+ * Dock id of the Messages feed (from `@devframes/plugin-messages`).
+ * Clicking a toast focuses the entry in that panel. Mirrors the plugin's
+ * `PLUGIN_ID`.
+ */
+export const MESSAGES_DOCK_ID = 'devframes_plugin_messages'
+
+export const BUILTIN_ENTRY_CLIENT_AUTH_NOTICE: DevframeViewBuiltin = Object.freeze({
+ type: '~builtin',
+ category: '~builtin',
+ id: '~client-auth-notice',
+ title: 'Unauthorized',
+ icon: 'ph:warning-duotone',
+})
+
+export const BUILTIN_ENTRIES: readonly DevframeViewBuiltin[] = Object.freeze([
+ BUILTIN_ENTRY_CLIENT_AUTH_NOTICE,
+])
+
+export { DEFAULT_CATEGORIES_ORDER } from '@devframes/hub/constants'
diff --git a/packages/hub-ui/src/client/embedded/index.ts b/packages/hub-ui/src/client/embedded/index.ts
new file mode 100644
index 00000000..24b50cf5
--- /dev/null
+++ b/packages/hub-ui/src/client/embedded/index.ts
@@ -0,0 +1,56 @@
+import type { DockPanelStorage } from '@devframes/hub/client'
+import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
+import { useLocalStorage } from '@vueuse/core'
+import { HUB_UI_HIDE_EVENT } from '../constants'
+import { DEFAULT_DOCK_PANEL_STORE } from '../state/docks'
+
+/**
+ * The floating-dock bootstrap the hub serves at `embedded.js` — load
+ * it with one `
+
+
diff --git a/packages/hub-ui/src/client/standalone/main.ts b/packages/hub-ui/src/client/standalone/main.ts
new file mode 100644
index 00000000..3b49c8e3
--- /dev/null
+++ b/packages/hub-ui/src/client/standalone/main.ts
@@ -0,0 +1,38 @@
+import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
+import { watchEffect } from 'vue'
+import { isDark } from '../state/color-mode'
+
+// The standalone viewer — a vanilla shell served at the hub base itself
+// (`DevframeHubUi.viewer`): resolve the shared connection, build the docks
+// context, and hand the whole viewport to the DockStandalone element. The
+// shell stays frameworkless on purpose; everything visual lives inside the
+// custom element's shadow root.
+
+// The viewer page runs in the light DOM, so mirror the color mode onto the
+// document element — its background and native controls follow the
+// Auto/Light/Dark choice.
+watchEffect(() => {
+ const el = document.documentElement
+ el.classList.toggle('dark', isDark.value)
+ el.classList.toggle('light', !isDark.value)
+ el.style.colorScheme = isDark.value ? 'dark' : 'light'
+})
+
+async function main(): Promise {
+ // Served at the hub base with relative assets: `./__connection.json`
+ // resolves against the page URL first, the module URL second.
+ const rpc = await getDevframeRpcClient({
+ baseURL: ['./', new URL('./', import.meta.url).href],
+ simpleAuth: false,
+ })
+
+ const { createDocksContext } = await import('../state/context')
+ const context = await createDocksContext('standalone', rpc)
+ setDevframeClientContext(context)
+
+ const { DockStandalone } = await import('../components/DockStandalone')
+ const el = new DockStandalone({ context }) as unknown as HTMLElement
+ document.getElementById('app')!.appendChild(el)
+}
+
+void main()
diff --git a/packages/hub-ui/src/client/standalone/vite.config.ts b/packages/hub-ui/src/client/standalone/vite.config.ts
new file mode 100644
index 00000000..6c494e83
--- /dev/null
+++ b/packages/hub-ui/src/client/standalone/vite.config.ts
@@ -0,0 +1,16 @@
+import { fileURLToPath } from 'node:url'
+import Vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vite'
+
+// The standalone viewer SPA the hub serves at its base
+// (`DevframeHubUi.viewer`). Built with relative asset paths so the output is
+// mount-path agnostic — the hub copies it verbatim wherever its base lives.
+export default defineConfig({
+ root: fileURLToPath(new URL('.', import.meta.url)),
+ base: './',
+ plugins: [Vue()],
+ build: {
+ outDir: fileURLToPath(new URL('../../../dist/client/standalone', import.meta.url)),
+ emptyOutDir: true,
+ },
+})
diff --git a/packages/hub-ui/src/client/state/color-mode.ts b/packages/hub-ui/src/client/state/color-mode.ts
new file mode 100644
index 00000000..e030b22c
--- /dev/null
+++ b/packages/hub-ui/src/client/state/color-mode.ts
@@ -0,0 +1,30 @@
+import { usePreferredDark, useStorage } from '@vueuse/core'
+import { computed } from 'vue'
+
+export type ColorSchemePreference = 'auto' | 'light' | 'dark'
+
+/**
+ * Shared with plugin SPAs served same-origin under the hub base, so writing
+ * this key here propagates into the iframes via `storage` events — one switch
+ * drives the dock shell and every inner integration at once.
+ */
+const COLOR_SCHEME_STORAGE_KEY = 'devframes-color-scheme'
+
+/** Persisted Auto / Light / Dark choice, defaulting to Auto (follow the OS). */
+export const colorSchemePreference = useStorage(
+ COLOR_SCHEME_STORAGE_KEY,
+ 'auto',
+)
+
+const preferredDark = usePreferredDark()
+
+/** Resolved dark state — `auto` defers to the OS `prefers-color-scheme`. */
+export const isDark = computed(() =>
+ colorSchemePreference.value === 'auto'
+ ? preferredDark.value
+ : colorSchemePreference.value === 'dark',
+)
+
+export function setColorSchemePreference(preference: ColorSchemePreference) {
+ colorSchemePreference.value = preference
+}
diff --git a/packages/hub-ui/src/client/state/commands.ts b/packages/hub-ui/src/client/state/commands.ts
new file mode 100644
index 00000000..1a216703
--- /dev/null
+++ b/packages/hub-ui/src/client/state/commands.ts
@@ -0,0 +1,189 @@
+import type { DevframeClientCommand, DevframeCommandEntry, DevframeCommandKeybinding, DevframeServerCommandEntry } from '@devframes/hub'
+import type { CommandsContext, DevframeRpcClient } from '@devframes/hub/client'
+import type { SharedState } from 'devframe/utils/shared-state'
+import type { WhenContext } from 'devframe/utils/when'
+import type { ShallowRef } from 'vue'
+import type { HubDocksUserSettings } from '../types'
+import { evaluateWhen } from 'devframe/utils/when'
+import { computed, markRaw, reactive, ref, watch } from 'vue'
+import { sharedStateToRef } from './docks'
+import { collectAllKeybindings, filterCommandsByWhen, normalizeKeyEvent } from './keybindings'
+import { useDockPopupWindow, useIsDockPopupOpen } from './popup'
+
+const commandsContextByRpc = new WeakMap()
+
+export async function createCommandsContext(
+ clientType: 'embedded' | 'standalone',
+ rpc: DevframeRpcClient,
+ settingsState: SharedState,
+ whenContextProvider?: () => WhenContext,
+): Promise {
+ if (commandsContextByRpc.has(rpc)) {
+ return commandsContextByRpc.get(rpc)!
+ }
+
+ // Server commands from shared state
+ const serverCommandsState = await rpc.sharedState.get('devframe:commands', { initialValue: [] })
+ const serverCommands: ShallowRef = sharedStateToRef(serverCommandsState)
+
+ // Client commands (local registry)
+ const clientCommands = reactive(new Map())
+
+ // Shortcut overrides from user settings
+ const settings = sharedStateToRef(settingsState)
+ const shortcutOverrides = computed(() => settings.value.commandShortcuts ?? {})
+
+ const paletteOpen = ref(false)
+ const isDockPopupOpen = useIsDockPopupOpen()
+
+ const getWhenContext = (): WhenContext => {
+ if (whenContextProvider)
+ return whenContextProvider()
+ return {
+ clientType,
+ dockOpen: false,
+ paletteOpen: paletteOpen.value,
+ dockSelectedId: '',
+ popupOpen: isDockPopupOpen.value,
+ }
+ }
+
+ // Merged commands
+ const commands = computed(() => [
+ ...serverCommands.value,
+ ...Array.from(clientCommands.values()),
+ ])
+
+ const paletteCommands = computed(() => {
+ const ctx = getWhenContext()
+ const available = filterCommandsByWhen(commands.value, ctx)
+ return available.filter(cmd => cmd.showInPalette !== false)
+ })
+
+ function register(cmd: DevframeClientCommand | DevframeClientCommand[]): () => void {
+ const cmds = Array.isArray(cmd) ? cmd : [cmd]
+ for (const c of cmds) {
+ clientCommands.set(c.id, c)
+ }
+ return () => {
+ for (const c of cmds) {
+ clientCommands.delete(c.id)
+ }
+ }
+ }
+
+ function findCommand(id: string): DevframeCommandEntry | undefined {
+ // Search top-level
+ const topLevel = commands.value.find(c => c.id === id)
+ if (topLevel)
+ return topLevel
+
+ // Search children
+ for (const cmd of commands.value) {
+ if (cmd.children) {
+ const child = cmd.children.find(c => c.id === id)
+ if (child)
+ return child as DevframeCommandEntry
+ }
+ }
+ return undefined
+ }
+
+ async function execute(id: string, ...args: any[]): Promise {
+ const cmd = findCommand(id)
+ if (!cmd) {
+ throw new Error(`Command "${id}" not found`)
+ }
+
+ // Check command-level when clause
+ if (cmd.when) {
+ const ctx = getWhenContext()
+ if (!evaluateWhen(cmd.when, ctx)) {
+ throw new Error(`Command "${id}" is not available in the current context`)
+ }
+ }
+
+ if (cmd.source === 'server') {
+ return rpc.call('hub:commands:execute', id, ...args)
+ }
+
+ // Client command
+ if (cmd.action) {
+ return cmd.action(...args)
+ }
+
+ throw new Error(`Command "${id}" has no action`)
+ }
+
+ function getKeybindings(id: string): DevframeCommandKeybinding[] {
+ const overrides = shortcutOverrides.value[id]
+ if (overrides !== undefined)
+ return overrides
+
+ const cmd = findCommand(id)
+ return cmd?.keybindings ?? []
+ }
+
+ // Keyboard shortcut listener
+ if (typeof window !== 'undefined') {
+ setupShortcutListener(getWhenContext, commands, getKeybindings, execute)
+ }
+
+ const commandsContext: CommandsContext = reactive({
+ commands,
+ paletteCommands,
+ register,
+ execute,
+ getKeybindings,
+ settings: markRaw(settingsState),
+ paletteOpen,
+ })
+
+ commandsContextByRpc.set(rpc, commandsContext)
+ return commandsContext
+}
+
+// --- Shortcut system ---
+
+function setupShortcutListener(
+ getWhenContext: () => WhenContext,
+ commands: { value: DevframeCommandEntry[] },
+ getKeybindings: (id: string) => DevframeCommandKeybinding[],
+ execute: (id: string, ...args: any[]) => Promise,
+) {
+ const handler = (e: KeyboardEvent) => {
+ const pressed = normalizeKeyEvent(e)
+ if (!pressed || pressed === 'Mod' || pressed === 'Shift' || pressed === 'Alt')
+ return
+
+ const whenCtx = getWhenContext()
+ const allBindings = collectAllKeybindings(commands, getKeybindings)
+
+ for (const { id, keybinding } of allBindings) {
+ if (keybinding.key !== pressed)
+ continue
+ // Check command-level when clause
+ const cmd: DevframeCommandEntry | undefined
+ = commands.value.find(c => c.id === id)
+ ?? commands.value.flatMap(c => c.children as DevframeCommandEntry[] ?? []).find(c => c.id === id)
+ if (cmd?.when && !evaluateWhen(cmd.when, whenCtx))
+ continue
+
+ e.preventDefault()
+ e.stopPropagation()
+ execute(id).catch(console.error)
+ return
+ }
+ }
+
+ // Attach to the host window. This covers embedded (shadow DOM) mode and the
+ // host page while a popup is open.
+ window.addEventListener('keydown', handler, { capture: true })
+
+ watch(useDockPopupWindow(), (popup, prev) => {
+ if (prev)
+ prev.removeEventListener('keydown', handler, { capture: true })
+ if (popup)
+ popup.addEventListener('keydown', handler, { capture: true })
+ })
+}
diff --git a/packages/hub-ui/src/client/state/confirm.ts b/packages/hub-ui/src/client/state/confirm.ts
new file mode 100644
index 00000000..ca2f8535
--- /dev/null
+++ b/packages/hub-ui/src/client/state/confirm.ts
@@ -0,0 +1,16 @@
+import { createTemplatePromise } from '@vueuse/core'
+
+export interface ConfirmOptions {
+ title?: string
+ message: string
+ confirmText?: string
+ cancelText?: string
+}
+
+export const ConfirmPromise = createTemplatePromise({
+ singleton: true,
+})
+
+export function useConfirm() {
+ return ConfirmPromise.start
+}
diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts
new file mode 100644
index 00000000..8d68983b
--- /dev/null
+++ b/packages/hub-ui/src/client/state/context.ts
@@ -0,0 +1,528 @@
+import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
+import type { CommandsContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DocksContext } from '@devframes/hub/client'
+import type { SharedState } from 'devframe/utils/shared-state'
+import type { WhenContext } from 'devframe/utils/when'
+import type { Ref } from 'vue'
+import type { HubDocksUserSettings } from './dock-settings'
+import { attachFrameNavClient } from '@devframes/hub/client'
+import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants'
+import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue'
+import { BUILTIN_ENTRIES, HUB_UI_HIDE_EVENT } from '../constants'
+import { createCommandsContext } from './commands'
+import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings'
+import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, sharedStateToRef, useDocksEntries } from './docks'
+import { createClientMessagesClient } from './messages-client'
+import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
+import { createDockRenderers } from './renderers'
+import { executeSetupScript } from './setup-script'
+
+const docksContextByRpc = new WeakMap()
+export async function createDocksContext(
+ clientType: 'embedded' | 'standalone',
+ rpc: DevframeRpcClient,
+ panelStore?: Ref,
+): Promise {
+ if (docksContextByRpc.has(rpc)) {
+ return docksContextByRpc.get(rpc)!
+ }
+
+ const dockEntries = await useDocksEntries(rpc)
+
+ // Client-only dock registry (0.7.10 `DocksEntriesContext` API). Docks
+ // registered here live in this page only, merged over the server-provided
+ // `devframe:docks` docks by id, and never sync to shared state. Mirrors the
+ // merge semantics of `@devframes/hub`'s own client host.
+ const clientDocks = reactive(new Map())
+ const entries = computed(() => {
+ const server = dockEntries.value
+ if (clientDocks.size === 0)
+ return server
+ const seen = new Set()
+ const merged: DevframeDockEntry[] = []
+ for (const entry of server) {
+ seen.add(entry.id)
+ // a client dock sharing a server id overrides it in the local merge
+ merged.push(clientDocks.get(entry.id) ?? entry)
+ }
+ for (const [id, entry] of clientDocks) {
+ if (!seen.has(id))
+ merged.push(entry)
+ }
+ return merged
+ })
+
+ const selectedId = ref(null)
+ const selected = computed(
+ () => entries.value.find(entry => entry.id === selectedId.value)
+ ?? BUILTIN_ENTRIES.find(entry => entry.id === selectedId.value)
+ ?? null,
+ )
+
+ const dockEntryStateMap: Map = reactive(new Map())
+ watchEffect(() => {
+ for (const entry of entries.value) {
+ if (dockEntryStateMap.has(entry.id)) {
+ dockEntryStateMap.get(entry.id)!.entryMeta = entry
+ continue
+ }
+ dockEntryStateMap.set(
+ entry.id,
+ createDockEntryState(entry, selected),
+ )
+ }
+ })
+
+ const registerClientDock = (entry: T, force = false): DockRegistration => {
+ if (clientDocks.has(entry.id) && !force)
+ throw new Error(`[@devframes/hub-ui] a client dock "${entry.id}" is already registered — pass force to overwrite`)
+ clientDocks.set(entry.id, entry)
+ // Eagerly materialize the entry's DockEntryState. The reactive watchEffect
+ // above only creates states on its next flush, but a caller such as the
+ // shared-iframe frame-nav adapter subscribes to `entry:activated` (via
+ // `getStateById`) synchronously right after registering — so the state has
+ // to exist immediately, mirroring hub's synchronous client-host reconcile.
+ if (!dockEntryStateMap.has(entry.id))
+ dockEntryStateMap.set(entry.id, createDockEntryState(entry, selected))
+ return {
+ update: (patch: Partial) => {
+ if (patch.id != null && patch.id !== entry.id)
+ throw new Error(`[@devframes/hub-ui] a client dock id is immutable ("${entry.id}")`)
+ const existing = clientDocks.get(entry.id)
+ if (existing)
+ clientDocks.set(entry.id, { ...existing, ...patch } as DevframeDockEntry)
+ },
+ dispose: () => {
+ if (!clientDocks.delete(entry.id))
+ return
+ dockEntryStateMap.delete(entry.id)
+ // Clearing the selection when the active member vanishes matches the
+ // hub reconcile behavior for a removed selected entry (shared-iframe
+ // soft-nav §7.5).
+ if (selectedId.value === entry.id)
+ selectedId.value = null
+ },
+ }
+ }
+ const updateClientDock = (entry: DevframeDockUserEntry) => {
+ if (!clientDocks.has(entry.id))
+ throw new Error(`[@devframes/hub-ui] no client dock "${entry.id}" to update — register it first`)
+ clientDocks.set(entry.id, entry as DevframeDockEntry)
+ }
+
+ panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE())
+ let docksContext: DocksContext
+
+ let _settingsStorePromise: Promise> | undefined
+ const getSettingsStore = async () => {
+ if (!_settingsStorePromise) {
+ _settingsStorePromise = rpc.sharedState.get(
+ 'devframe:user-settings',
+ { initialValue: DEFAULT_STATE_USER_SETTINGS() },
+ )
+ }
+ return _settingsStorePromise
+ }
+
+ // Get settings store ahead of `switchEntry` — its group→member resolution
+ // needs `getWhenContext` to honor a `defaultChildId` target's `when` clause.
+ const settingsStore = markRaw(await getSettingsStore())
+ const settings = sharedStateToRef(settingsStore)
+
+ // Shared when-context provider — used by both commands and docks
+ let commandsContext: CommandsContext
+ const isDockPopupOpen = useIsDockPopupOpen()
+ const getWhenContext = (): WhenContext => ({
+ clientType,
+ dockOpen: panelStore.value.open,
+ paletteOpen: commandsContext?.paletteOpen ?? false,
+ dockSelectedId: selectedId.value ?? '',
+ popupOpen: isDockPopupOpen.value,
+ })
+
+ // Tracks the shared frame's current member tab, keyed by `frameId`. A
+ // `subTabs` anchor boots a shared iframe but has no view distinct from its
+ // synthesized member tabs (they all render the same frame), and it is usually
+ // hidden from the bar via `visibility: 'false'`. Remembering which member is
+ // live lets `switchEntry` redirect a later re-selection of the anchor (e.g. a
+ // group `defaultChildId` reopening the group) onto that visible tab instead of
+ // lingering on the invisible anchor. Populated below whenever a member is
+ // selected; read when a `subTabs` anchor is selected.
+ const frameNavCurrentMember = new Map()
+
+ const switchEntry = async (id: string | null = null) => {
+ if (id == null) {
+ selectedId.value = null
+ panelStore.value.open = false
+ return true
+ }
+ if (id === '~client-auth-notice') {
+ selectedId.value = id
+ panelStore.value.open = true
+ return true
+ }
+ const entry = entries.value.find(e => e.id === id)
+ if (!entry)
+ return false
+
+ // A group has no view of its own — resolve to the member it represents.
+ // Prefer the author's `defaultChildId` (honoring its `when` clause but
+ // ignoring its render-only `visibility` — see `resolveGroupDefaultChild`),
+ // otherwise the first member. With neither, the group is popover-only and
+ // selecting it is a no-op here (the dock-bar group button opens the
+ // member popover instead).
+ if (entry.type === 'group') {
+ const target = resolveGroupDefaultChild(entries.value, entry.id, entry.defaultChildId, getWhenContext())?.id
+ ?? getGroupMembers(entries.value, entry.id)[0]?.id
+ if (!target)
+ return false
+ return switchEntry(target)
+ }
+
+ // A `subTabs` anchor owns the shared frame but has no view of its own apart
+ // from its synthesized member tabs, and is usually hidden from the bar
+ // (`visibility: 'false'`). Once the frame has reported a current tab,
+ // selecting the anchor — via a group `defaultChildId` boot, the command
+ // palette, or an RPC activation — redirects to that live member so a visible
+ // dock is highlighted instead of the invisible anchor. Before any tab exists
+ // (first boot) there is no current member, so we fall through and select the
+ // anchor itself to mount its iframe and boot the frame.
+ if (entry.type === 'iframe' && entry.subTabs) {
+ const frameId = entry.frameId ?? entry.id
+ const currentMemberId = frameNavCurrentMember.get(frameId)
+ if (currentMemberId && currentMemberId !== id && entries.value.some(e => e.id === currentMemberId))
+ return switchEntry(currentMemberId)
+ }
+
+ // If the action is in a popup, delegate to the main frame
+ if (entry.type === 'action') {
+ const delegated = await triggerMainFrameDockAction(clientType, entry.id)
+ if (delegated != null)
+ return false
+ }
+
+ // If has import script, run it
+ if (
+ (entry.type === 'action')
+ || (entry.type === 'custom-render')
+ || (entry.type === 'iframe' && entry.clientScript)
+ ) {
+ const current = dockEntryStateMap.get(id)!
+ const messagesClient = createClientMessagesClient(rpc)
+ const scriptContext: DockClientScriptContext = reactive({
+ ...toRefs(docksContext) as any,
+ current,
+ messages: messagesClient,
+ logs: messagesClient,
+ })
+ await executeSetupScript(entry, scriptContext)
+ }
+
+ // Remember the shared frame's current member tab (a member carries its
+ // anchor's `frameId` but is not itself a `subTabs` anchor) so re-selecting
+ // the usually-hidden anchor later lands back on this visible tab.
+ if (entry.type === 'iframe' && entry.frameId && !entry.subTabs)
+ frameNavCurrentMember.set(entry.frameId, entry.id)
+
+ selectedId.value = entry.id
+ panelStore.value.open = true
+ return true
+ }
+
+ const toggleEntry = async (id: string) => {
+ if (selectedId.value === id)
+ return switchEntry(null)
+ return switchEntry(id)
+ }
+
+ // Shared-iframe soft navigation (devframe 0.7.11). An iframe dock flagged
+ // `subTabs` is an *anchor* that owns one live iframe (its `frameId`); the
+ // embedded app ships a small `postMessage` nav shim. When the anchor's iframe
+ // mounts we attach the hub-shipped frame-nav adapter, which runs the ready
+ // handshake, turns the reported tab manifest into client-only member docks,
+ // and drives the bidirectional nav loop (selecting a member soft-navigates
+ // the shared frame; the app's `navigated` report moves the dock highlight).
+ //
+ // Our shell runs its own dock machinery instead of hub's `createDevframeClientHost`,
+ // so we replicate the host's `maybeAttachFrameNav`: one adapter per `frameId`,
+ // torn down when the anchor is removed.
+ //
+ // The adapter is bound to a *mounted iframe element*, not just to the `frameId`.
+ // Each dock shell (float, edge, popup) owns its own `IframePanes` manager and
+ // creates panes in its own document, so switching shells hands us a different
+ // iframe in a different realm — the old adapter is disposed and a fresh one
+ // attached. For the same reason the adapter must listen on the iframe's own
+ // window: in popup mode the frame lives in a Document-PiP document and posts
+ // its handshake to *that* window, not to the main one.
+ const frameNavAdapters = new Map void }>()
+ const frameNavAnchors = new Map()
+
+ const attachFrameNav = (anchor: DevframeViewIframe, state: DockEntryState) => {
+ const frameId = anchor.frameId ?? anchor.id
+ const start = (iframe: HTMLIFrameElement) => {
+ const existing = frameNavAdapters.get(frameId)
+ if (existing) {
+ if (existing.iframe === iframe)
+ return
+ existing.dispose()
+ frameNavAdapters.delete(frameId)
+ }
+ const adapter = attachFrameNavClient({
+ frameId,
+ anchor,
+ iframe,
+ window: iframe.ownerDocument?.defaultView ?? globalThis,
+ docks: {
+ register: registerClientDock,
+ switchEntry,
+ getStateById: (id: string) => dockEntryStateMap.get(id),
+ },
+ })
+ frameNavAdapters.set(frameId, { iframe, dispose: adapter.dispose })
+ }
+ if (state.domElements.iframe)
+ start(state.domElements.iframe)
+ state.events.on('dom:iframe:mounted', start)
+ }
+
+ watch(
+ entries,
+ (list) => {
+ const seen = new Set()
+ for (const meta of list) {
+ if (meta.type !== 'iframe' || !meta.subTabs)
+ continue
+ seen.add(meta.id)
+ if (frameNavAnchors.has(meta.id))
+ continue
+ const state = dockEntryStateMap.get(meta.id)
+ if (!state)
+ continue
+ frameNavAnchors.set(meta.id, meta.frameId ?? meta.id)
+ attachFrameNav(meta, state)
+ }
+ // An anchor that disappeared tears down its adapter, which disposes every
+ // member dock it registered and detaches the `postMessage` listener.
+ for (const [anchorId, frameId] of [...frameNavAnchors]) {
+ if (seen.has(anchorId))
+ continue
+ frameNavAnchors.delete(anchorId)
+ frameNavAdapters.get(frameId)?.dispose()
+ frameNavAdapters.delete(frameId)
+ }
+ },
+ { immediate: true },
+ )
+
+ // Honor cross-iframe dock-activation requests (devframe 0.7.3). A mounted
+ // plugin — or our own launcher's "View in Terminal" action — calls the
+ // `hub:docks:activate` RPC; the hub broadcasts `devframe:docks:activate` to
+ // every client. Our shell runs its own dock machinery rather than hub's
+ // client host, so we handle the broadcast here and switch the active dock
+ // ourselves. The target dock (e.g. Terminals) reads `activation.params` to
+ // focus a specific session.
+ rpc.client.register({
+ name: 'devframe:docks:activate' satisfies keyof DevframeRpcClientFunctions,
+ type: 'action',
+ handler: (activation: { dockId: string, params?: Record }) => {
+ if (activation?.dockId)
+ switchEntry(activation.dockId)
+ },
+ })
+
+ // Settings store, `settings`, and `getWhenContext` are established earlier
+ // (right before `switchEntry`) — its group→member resolution needs them.
+ const groupedEntries = computed(() => {
+ return docksGroupByCategories(entries.value, settings.value, { whenContext: getWhenContext(), collapseGroups: true })
+ })
+
+ // Initialize commands context with reactive when-context
+ const commandsContextResult = await createCommandsContext(clientType, rpc, settingsStore, getWhenContext)
+ commandsContext = commandsContextResult
+
+ // Register built-in client commands
+ commandsContext.register([
+ {
+ id: 'devframes:toggle-palette',
+ source: 'client',
+ title: 'Toggle Command Palette',
+ icon: 'ph:magnifying-glass-duotone',
+ showInPalette: false,
+ keybindings: [{ key: 'Mod+K' }],
+ action: () => {
+ commandsContext.paletteOpen = !commandsContext.paletteOpen
+ },
+ },
+ {
+ id: 'devframes:close-panel',
+ source: 'client',
+ title: 'Close Panel',
+ icon: 'ph:x-circle-duotone',
+ when: 'dockOpen && !paletteOpen',
+ keybindings: [{ key: 'Escape' }],
+ action: () => {
+ panelStore.value.open = false
+ selectedId.value = null
+ },
+ },
+ {
+ id: 'devframes:open-settings',
+ source: 'client',
+ title: 'Open Settings',
+ icon: 'ph:gear-duotone',
+ action: () => {
+ switchEntry('~settings')
+ },
+ },
+ {
+ id: 'devframes:hide',
+ source: 'client',
+ title: 'Hide Devframes',
+ icon: 'ph:eye-slash-duotone',
+ // Only the embedded overlay can be dismissed; the standalone page is an
+ // explicit visit and stays mounted.
+ when: 'clientType == embedded',
+ action: () => {
+ // Ask whoever mounted the embedded dock to tear it down for this
+ // session — a reload brings it back.
+ window.dispatchEvent(new CustomEvent(HUB_UI_HIDE_EVENT))
+ },
+ },
+ {
+ id: 'devframes:dock-mode',
+ source: 'client',
+ title: 'Dock Mode',
+ icon: 'ph:layout-duotone',
+ // While the popup is open the embedded shell is unmounted and the popup
+ // renders the standalone layout, so neither mode is observable — mirrors
+ // the Appearance settings hiding its own dock-mode control.
+ when: clientType === 'embedded' ? 'clientType == embedded && !popupOpen' : undefined,
+ children: [
+ {
+ id: 'devframes:dock-mode:float',
+ source: 'client',
+ title: 'Float Mode',
+ icon: 'ph:cards-three-duotone',
+ // Repeated per child: shortcut dispatch reads the matched command's
+ // own `when` and does not inherit the parent's.
+ when: '!popupOpen',
+ action: () => {
+ panelStore.value.mode = 'float'
+ },
+ },
+ {
+ id: 'devframes:dock-mode:edge',
+ source: 'client',
+ title: 'Edge Mode',
+ icon: 'ph:square-half-bottom-duotone',
+ when: '!popupOpen',
+ action: () => {
+ panelStore.value.mode = 'edge'
+ },
+ },
+ ],
+ },
+ ])
+
+ // Dynamic dock navigation commands — grouped under "Docks" parent
+ let cleanupDocksCommand: (() => void) | undefined
+ watchEffect(() => {
+ cleanupDocksCommand?.()
+
+ const toCommand = (entry: DevframeDockEntry): DevframeClientCommand => ({
+ id: `devframes:docks:${entry.id}`,
+ source: 'client' as const,
+ title: entry.title,
+ icon: resolveCommandIcon(entry.icon),
+ action: () => {
+ toggleEntry(entry.id)
+ },
+ })
+
+ // Mirror the dock-bar collapse in the palette: members nest under their
+ // group's command, and grouped members drop out of the top level.
+ const registeredGroupIds = getRegisteredGroupIds(entries.value)
+ const dockChildren: DevframeClientCommand[] = entries.value
+ .filter(entry => entry.type !== '~builtin')
+ .filter(entry => !(entry.groupId && registeredGroupIds.has(entry.groupId)))
+ .map((entry) => {
+ if (entry.type !== 'group')
+ return toCommand(entry)
+ // Members nest under the group, split by their in-group sub-category.
+ // A single sub-category (the common case) is flattened directly so the
+ // palette doesn't add a pointless one-item drill-down level.
+ const memberGroups = getGroupMembersGrouped(entries.value, entry.id, settings.value, { whenContext: getWhenContext() })
+ const children: DevframeClientCommand[] = memberGroups.length <= 1
+ ? (memberGroups[0]?.[1] ?? []).map(toCommand)
+ : memberGroups.map(([category, members]) => ({
+ id: `devframes:docks:${entry.id}:cat:${category}`,
+ source: 'client' as const,
+ title: getCategoryLabel(category),
+ children: members.map(toCommand),
+ }))
+ return {
+ ...toCommand(entry),
+ children,
+ }
+ })
+
+ if (dockChildren.length > 0) {
+ cleanupDocksCommand = commandsContext.register({
+ id: 'devframes:docks',
+ source: 'client',
+ title: 'Docks',
+ icon: 'ph:layout-duotone',
+ children: dockChildren,
+ })
+ }
+ })
+
+ docksContext = reactive({
+ panel: {
+ store: panelStore,
+ isDragging: false,
+ isResizing: false,
+ isVertical: computed(() => panelStore.value.position === 'left' || panelStore.value.position === 'right'),
+ },
+ docks: {
+ selectedId,
+ selected,
+ entries,
+ entryToStateMap: markRaw(dockEntryStateMap),
+ groupedEntries,
+ settings: settingsStore,
+ getStateById: (id: string) => dockEntryStateMap.get(id),
+ switchEntry,
+ toggleEntry,
+ register: registerClientDock,
+ update: updateClientDock,
+ },
+ commands: commandsContext,
+ when: {
+ get context() {
+ return getWhenContext()
+ },
+ },
+ connection: {
+ get status() {
+ return rpc.status
+ },
+ get error() {
+ return rpc.connectionError
+ },
+ events: rpc.events,
+ },
+ renderers: markRaw(createDockRenderers(() => docksContext)),
+ rpc: markRaw(rpc),
+ clientType,
+ })
+
+ registerMainFrameDockActionHandler(clientType, async (id) => {
+ const entry = entries.value.find(e => e.id === id)
+ if (!entry || entry.type !== 'action')
+ return false
+ return switchEntry(entry.id)
+ })
+
+ docksContextByRpc.set(rpc, docksContext)
+ return docksContext
+}
diff --git a/packages/hub-ui/src/client/state/dock-settings.ts b/packages/hub-ui/src/client/state/dock-settings.ts
new file mode 100644
index 00000000..d882053c
--- /dev/null
+++ b/packages/hub-ui/src/client/state/dock-settings.ts
@@ -0,0 +1,443 @@
+import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
+import type { Immutable } from 'devframe/utils/shared-state'
+import type { WhenContext } from 'devframe/utils/when'
+import type { HubDocksUserSettings } from '../types'
+import { evaluateWhen } from 'devframe/utils/when'
+import { DEFAULT_CATEGORIES_ORDER, INSPECTOR_DOCK_ID } from '../constants'
+
+export type { HubDocksUserSettings }
+export type { DevframeDockEntriesGrouped }
+
+/**
+ * Synthetic category that collects pinned dock entries. Pinning re-buckets an
+ * entry here instead of merely floating it to the top of its home category, so
+ * pinned entries lead the dock bar (and, for grouped members, lead inside their
+ * group). The `~` prefix marks it internal, mirroring `~builtin`; it is never
+ * user-hideable and does not exist upstream in `DEFAULT_CATEGORIES_ORDER`.
+ */
+const PINNED_CATEGORY = '~pinned'
+
+/**
+ * Order weight for {@link PINNED_CATEGORY}. Strongly negative so the Pinned
+ * bucket always sorts before every real category (`framework` leads the
+ * upstream table at `-100`). Applied as a local override in the sort rather
+ * than added to the upstream `DEFAULT_CATEGORIES_ORDER` table, keeping the pin
+ * feature entirely client-side.
+ */
+const PINNED_CATEGORY_ORDER = -100000
+
+/**
+ * Resolve a category's sort weight, layering the local {@link PINNED_CATEGORY}
+ * override, then a caller-supplied `overrides` map (a group's own
+ * {@link DevframeViewGroup.categoryOrder}), on top of the upstream
+ * {@link DEFAULT_CATEGORIES_ORDER} table. `overrides` is per-call — passing a
+ * group's map only reweights that group's in-group sub-categories, never the
+ * outer bar or any other group.
+ */
+function categoryOrder(category: string, overrides?: Record): number {
+ if (category === PINNED_CATEGORY)
+ return PINNED_CATEGORY_ORDER
+ return overrides?.[category] ?? DEFAULT_CATEGORIES_ORDER[category] ?? 0
+}
+
+export interface SplitGroupsResult {
+ visible: DevframeDockEntriesGrouped
+ overflow: DevframeDockEntriesGrouped
+}
+
+/**
+ * Resolve a dock entry's icon down to a single icon string.
+ *
+ * A dock icon may be a string or a `{ light, dark }` pair, but a command's icon
+ * is string-only. When projecting dock entries into commands (palette + Shortcuts
+ * settings) we collapse the object form to a single string rather than dropping
+ * it — otherwise object-icon docks (e.g. a branded dock group) lose their
+ * icon entirely. Returns `undefined` only when no icon is available.
+ */
+export function resolveCommandIcon(icon: DevframeDockEntry['icon']): string | undefined {
+ if (typeof icon === 'string')
+ return icon
+ return icon?.light ?? icon?.dark
+}
+
+const CATEGORY_LABELS: Record = {
+ 'default': 'Default',
+ 'app': 'App',
+ 'framework': 'Framework',
+ 'web': 'Web',
+ 'advanced': 'Advanced',
+ '~builtin': 'Built-in',
+ [PINNED_CATEGORY]: 'Pinned',
+}
+
+/**
+ * Internal categories the user cannot hide via `docksCategoriesHidden`. Both
+ * are `~`-prefixed synthetic buckets: `~builtin` (always-present built-ins) and
+ * `~pinned` (the pinned bucket, whose membership the user controls per-entry
+ * via the pin toggle instead).
+ */
+export function isCategoryHideable(category: string): boolean {
+ return category !== '~builtin' && category !== PINNED_CATEGORY
+}
+
+/**
+ * Human label for a dock category id, used for category headers (settings) and
+ * in-group sub-category nodes (command palette). Falls back to the raw id for
+ * custom categories a kit may register.
+ */
+export function getCategoryLabel(category: string): string {
+ return CATEGORY_LABELS[category] ?? category
+}
+
+/**
+ * Collect the ids of every registered dock group (`type: 'group'`).
+ *
+ * Grouping is one level deep, so a group entry never points at another group;
+ * this set is the authority for deciding whether an entry's `groupId` resolves
+ * to a real group (membership) or dangles (orphan — rendered as a normal
+ * top-level entry).
+ */
+export function getRegisteredGroupIds(entries: DevframeDockEntry[]): Set {
+ const ids = new Set()
+ for (const entry of entries) {
+ if (entry.type === 'group')
+ ids.add(entry.id)
+ }
+ return ids
+}
+
+/**
+ * Resolve the group entry an entry belongs to, or `undefined` when the entry
+ * is top-level or its `groupId` references a group that was never registered.
+ */
+export function getEntryGroup(
+ entries: DevframeDockEntry[],
+ entry: DevframeDockEntry | null | undefined,
+): DevframeViewGroup | undefined {
+ if (!entry || entry.type === 'group' || !entry.groupId)
+ return undefined
+ const group = entries.find(e => e.id === entry.groupId)
+ return group?.type === 'group' ? group : undefined
+}
+
+/**
+ * Group a group's members by their **in-group sub-category** and sort them the
+ * same way the dock bar sorts (custom order, then default order). Members
+ * hidden by user settings or a falsy `when` clause are filtered out unless
+ * `includeHidden`.
+ *
+ * A member's own `category` field is its in-group sub-category (defaulting to
+ * `'default'`) — the group's `category` is the *outer* bucket the whole group
+ * lives in, so it never bleeds into the sub-category split here. A pinned member
+ * moves to a `~pinned` sub-category (leading the group, via
+ * {@link PINNED_CATEGORY_ORDER}). Sub-categories are ordered by the same
+ * {@link DEFAULT_CATEGORIES_ORDER} table as top-level categories — unless the
+ * group entry itself sets {@link DevframeViewGroup.categoryOrder}, whose
+ * weights take precedence for this group's sub-categories only, leaving every
+ * other group and the outer bar on the shared table. Sub-categories are not
+ * independently hideable (the outer category-hide toggle does not apply
+ * inside a group).
+ */
+export function getGroupMembersGrouped(
+ entries: DevframeDockEntry[],
+ groupId: string,
+ settings?: Immutable,
+ options?: { includeHidden?: boolean, whenContext?: WhenContext },
+): DevframeDockEntriesGrouped {
+ const members = entries.filter(e => e.type !== 'group' && e.groupId === groupId)
+ if (!settings)
+ return members.length ? [['default', members]] : []
+ const group = entries.find((e): e is DevframeViewGroup => e.type === 'group' && e.id === groupId)
+ // Group by the members' own `category` (the in-group sub-category), never the
+ // group's category. Category-hide is an outer-bar concern, so it is ignored.
+ // The group's own `categoryOrder`, if set, reweights only this split.
+ return docksGroupByCategories(members, settings, { ...options, ignoreCategoryHidden: true, categoryOrderOverride: group?.categoryOrder })
+}
+
+/**
+ * List the member entries of a group as a flat array, preserving the same
+ * sub-category order + sorting {@link getGroupMembersGrouped} produces. Members
+ * hidden by user settings or a falsy `when` clause are filtered out unless
+ * `includeHidden`. Use this where the caller only needs the members in display
+ * order (e.g. the group button's active check, empty-group detection); use
+ * {@link getGroupMembersGrouped} where the in-group sub-category split matters.
+ */
+export function getGroupMembers(
+ entries: DevframeDockEntry[],
+ groupId: string,
+ settings?: Immutable,
+ options?: { includeHidden?: boolean, whenContext?: WhenContext },
+): DevframeDockEntry[] {
+ return getGroupMembersGrouped(entries, groupId, settings, options).flatMap(([, items]) => items)
+}
+
+/**
+ * Resolve a group's `defaultChildId` to its target member, for the "clicking
+ * the group button jumps straight to this member" behavior (the dock-bar
+ * button and `switchEntry`'s own group→member resolution both need this).
+ *
+ * Respects the member's own `when` clause — a conditionally-unavailable
+ * target (e.g. `when: 'clientType == embedded'` evaluating false) is not a
+ * valid default and this returns `undefined` so the caller falls back to its
+ * own behavior (open the popover, or pick another member). Deliberately
+ * ignores the render-only `visibility` clause: `visibility` never affects
+ * reachability (see {@link docksGroupByCategories}'s `visibility` check), and
+ * jumping straight to a `defaultChildId` target is exactly the kind of
+ * id-based activation the render-only contract says stays unaffected — only
+ * the target's own dock-bar button (if it has one) should disappear.
+ */
+export function resolveGroupDefaultChild(
+ entries: DevframeDockEntry[],
+ groupId: string,
+ defaultChildId: string | undefined,
+ whenContext?: WhenContext,
+): DevframeDockEntry | undefined {
+ if (!defaultChildId)
+ return undefined
+ const member = entries.find(e => e.type !== 'group' && e.groupId === groupId && e.id === defaultChildId)
+ if (!member)
+ return undefined
+ if (member.when) {
+ if (whenContext) {
+ if (!evaluateWhen(member.when, whenContext))
+ return undefined
+ }
+ else if (member.when === 'false') {
+ return undefined
+ }
+ }
+ return member
+}
+
+/**
+ * Group and sort dock entries based on user settings.
+ * Filters out hidden entries and categories, then sorts by custom order and
+ * default order within each category.
+ *
+ * Both `when` and its render-only counterpart `visibility` only ever drop an
+ * entry out of the grouped result *this call* produces — the entry always
+ * remains in the caller's raw `entries` array, so activation, RPC, and the
+ * `subTabs` frame-nav adapter (which read `entries` directly rather than a
+ * grouped result) are unaffected by either clause.
+ *
+ * Outer bucketing follows the dual role of `category`: a grouped member whose
+ * `groupId` resolves to a registered group takes that **group's** `category` as
+ * its outer bucket (its own `category` is the in-group sub-category instead).
+ * When `collapseGroups` is set those members are folded away entirely and only
+ * the group entry — carrying the group's own `category` — represents them on the
+ * bar, so the outer bucket is always the group's category. Orphan members
+ * (whose `groupId` references no registered group) fall back to their own
+ * `category`.
+ *
+ * Pinning re-buckets an entry into {@link PINNED_CATEGORY} in place of the
+ * category slot it would otherwise occupy — the outer bucket for a top-level
+ * entry or group button, or the in-group sub-category for a member (the
+ * members-only in-group split has no group entries, so `resolvedGroupCategory`
+ * is undefined there and the member's own category slot is the one replaced).
+ * A grouped member's outer bucket is never re-pinned, so pinning a member
+ * reorders it inside its group rather than promoting it onto the top-level bar.
+ * Because the pinned bucket is chosen before the category-hide check and is
+ * itself never hideable, a pinned entry stays visible even when its original
+ * category is hidden.
+ *
+ * `categoryOrderOverride` reweights the categories produced by *this call*
+ * (used by {@link getGroupMembersGrouped} to apply a group's own
+ * {@link DevframeViewGroup.categoryOrder} to its in-group sub-category split)
+ * — it never touches the shared {@link DEFAULT_CATEGORIES_ORDER} table, so it
+ * has no effect on any other call, group, or the outer bar.
+ */
+export function docksGroupByCategories(
+ entries: DevframeDockEntry[],
+ settings: Immutable,
+ options?: { includeHidden?: boolean, whenContext?: WhenContext, collapseGroups?: boolean, ignoreCategoryHidden?: boolean, categoryOrderOverride?: Record },
+): DevframeDockEntriesGrouped {
+ const { docksHidden, docksCategoriesHidden, docksCustomOrder, docksPinned } = settings
+ const { includeHidden = false, whenContext, collapseGroups = false, ignoreCategoryHidden = false, categoryOrderOverride } = options ?? {}
+
+ // Map every registered group id to its resolved outer category. A grouped
+ // member's OUTER bucket is its group's category (the member's own `category`
+ // is its in-group sub-category); the group entry itself carries this same
+ // category. When only members are passed (the in-group split), no group entry
+ // is present here, so members fall back to their own `category` — exactly the
+ // sub-category behaviour we want.
+ const groupCategories = new Map()
+ for (const entry of entries) {
+ if (entry.type === 'group')
+ groupCategories.set(entry.id, entry.category ?? 'default')
+ }
+
+ const map = new Map()
+ for (const entry of entries) {
+ const resolvedGroupCategory = entry.type !== 'group' && entry.groupId
+ ? groupCategories.get(entry.groupId)
+ : undefined
+
+ // Collapse grouped members out of the top-level bar (orphans stay visible)
+ if (collapseGroups && resolvedGroupCategory !== undefined)
+ continue
+
+ // Skip if hidden by `when` clause
+ if (entry.when && whenContext && !evaluateWhen(entry.when, whenContext) && !includeHidden)
+ continue
+ if (entry.when && !whenContext && entry.when === 'false' && !includeHidden)
+ continue
+ // Skip if hidden by the render-only `visibility` clause. Unlike `when`,
+ // `visibility` never affects the entry's registration or reachability —
+ // it only decides whether *this call* (a dock-bar/popover/sidebar render)
+ // includes the entry's own button. Callers that need the entry regardless
+ // (activation, RPC, the `subTabs` frame-nav adapter, the settings
+ // management view via `includeHidden`) read `entries` directly and are
+ // unaffected by this check.
+ if (entry.visibility && whenContext && !evaluateWhen(entry.visibility, whenContext) && !includeHidden)
+ continue
+ if (entry.visibility && !whenContext && entry.visibility === 'false' && !includeHidden)
+ continue
+ // The Devframe Inspector is hidden by default; it only joins the dock bar
+ // once opted into via Settings → Advanced. The settings management view
+ // (`includeHidden`) still lists it so users can discover the toggle.
+ if (!includeHidden && entry.id === INSPECTOR_DOCK_ID && !settings.showDevframeInspector)
+ continue
+ if (!includeHidden && docksHidden.includes(entry.id))
+ continue
+
+ // Outer bucket: the group's category for grouped members, else the entry's
+ // own category. Orphans (groupId with no registered group) fall through to
+ // their own `category`.
+ const ownCategory = resolvedGroupCategory ?? entry.category ?? 'default'
+ // A pinned entry re-buckets into `~pinned` in place of the category slot it
+ // occupies — but only that slot. Top-level entries and group buttons
+ // (`resolvedGroupCategory === undefined`) move to the top-level `~pinned`
+ // bucket; members in the in-group split (also undefined there) move to a
+ // `~pinned` sub-category. A grouped member's OUTER bucket
+ // (`resolvedGroupCategory` defined) is left alone, so pin never promotes it
+ // off its group and onto the bar.
+ const category = docksPinned.includes(entry.id) && resolvedGroupCategory === undefined
+ ? PINNED_CATEGORY
+ : ownCategory
+ // Skip if category is hidden (an outer-bar concern; not applied in-group).
+ // `~pinned` is never hideable, so a pinned entry survives its original
+ // category being hidden.
+ if (!includeHidden && !ignoreCategoryHidden && docksCategoriesHidden.includes(category))
+ continue
+
+ if (!map.has(category))
+ map.set(category, [])
+ map.get(category)!.push(entry)
+ }
+
+ const grouped = Array
+ .from(map.entries())
+ .sort(([a], [b]) => {
+ const ia = categoryOrder(a, categoryOrderOverride)
+ const ib = categoryOrder(b, categoryOrderOverride)
+ return ib === ia ? b.localeCompare(a) : ia - ib
+ })
+
+ grouped.forEach(([_, items]) => {
+ // Ordering within a category (including the `~pinned` bucket, where every
+ // entry is pinned): custom order first, then default order, then title.
+ items.sort((a, b) => {
+ // Custom order
+ const customOrderA = docksCustomOrder[a.id] ?? 0
+ const customOrderB = docksCustomOrder[b.id] ?? 0
+ if (customOrderA !== customOrderB)
+ return customOrderA - customOrderB
+
+ // Default order
+ const ia = a.defaultOrder ?? 0
+ const ib = b.defaultOrder ?? 0
+ return ib === ia ? b.title.localeCompare(a.title) : ia - ib
+ })
+ })
+
+ return grouped
+}
+
+export interface SidebarCapacityOptions {
+ /** Measured height of the rail root, in px. */
+ availableHeight: number
+ /**
+ * Fixed vertical overhead that is always present regardless of member count:
+ * the root's padding, the pinned group anchor, and the anchor divider.
+ */
+ reservedHeight: number
+ /** Height of one member button, including its inter-item gap. */
+ itemHeight: number
+ /** Height of one sub-category divider, including its gaps. */
+ dividerHeight: number
+ /** Height of the "show more" button, including its gap. */
+ moreButtonHeight: number
+ /** Number of sub-category dividers that could render (sub-categories − 1). */
+ dividerCount: number
+ /** Total member count across every sub-category. */
+ totalItems: number
+}
+
+/**
+ * Derive how many group side nav member buttons fit in the measured rail height.
+ *
+ * Two-pass: first test whether every member fits with no show-more button; if
+ * they do, the full count is returned (no button, no overflow). Otherwise the
+ * show-more button's height is reserved and the capacity recomputed, so the
+ * button only ever costs a slot when it is actually shown.
+ *
+ * The sub-category divider budget is subtracted up front for every divider that
+ * might render, keeping the estimate conservative — the rail may fold one
+ * member early into the popover, but it never clips.
+ */
+export function deriveSidebarCapacity(options: SidebarCapacityOptions): number {
+ const { availableHeight, reservedHeight, itemHeight, dividerHeight, moreButtonHeight, dividerCount, totalItems } = options
+
+ if (totalItems <= 0 || itemHeight <= 0)
+ return 0
+
+ const budget = availableHeight - reservedHeight - Math.max(0, dividerCount) * dividerHeight
+
+ // Pass 1: does everything fit without a show-more button?
+ const fitWithoutButton = Math.floor(budget / itemHeight)
+ if (fitWithoutButton >= totalItems)
+ return totalItems
+
+ // Pass 2: overflow is unavoidable, so reserve the show-more button's slot.
+ const fitWithButton = Math.floor((budget - moreButtonHeight) / itemHeight)
+ return Math.max(0, fitWithButton)
+}
+
+/**
+ * Split grouped entries into visible and overflow based on capacity.
+ *
+ * A lone overflowing entry folds back into `visible` instead of staying in
+ * `overflow` — a whole overflow affordance (button + badge + popover) just to
+ * reveal one icon costs more chrome than it saves, so that one entry renders
+ * inline in the slot the affordance would have occupied. Folding only
+ * triggers for exactly one overflowing entry; two or more still overflow
+ * normally.
+ */
+export function docksSplitGroupsWithCapacity(
+ groups: DevframeDockEntriesGrouped,
+ capacity: number,
+): SplitGroupsResult {
+ const visible: DevframeDockEntriesGrouped = []
+ const overflow: DevframeDockEntriesGrouped = []
+ let left = capacity
+
+ for (const [category, items] of groups) {
+ if (left <= 0) {
+ overflow.push([category, items])
+ }
+ else if (items.length > left) {
+ visible.push([category, items.slice(0, left)])
+ overflow.push([category, items.slice(left)])
+ left = 0
+ }
+ else {
+ left -= items.length
+ visible.push([category, items])
+ }
+ }
+
+ if (overflow.reduce((acc, [, items]) => acc + items.length, 0) === 1)
+ return { visible: [...visible, ...overflow], overflow: [] }
+
+ return { visible, overflow }
+}
diff --git a/packages/hub-ui/src/client/state/docks.ts b/packages/hub-ui/src/client/state/docks.ts
new file mode 100644
index 00000000..88f70b6a
--- /dev/null
+++ b/packages/hub-ui/src/client/state/docks.ts
@@ -0,0 +1,86 @@
+import type { DevframeDockEntry } from '@devframes/hub'
+import type { DevframeRpcClient, DockEntryState, DockEntryStateEvents, DockPanelStorage } from '@devframes/hub/client'
+import type { SharedState } from 'devframe/utils/shared-state'
+import type { Ref, ShallowRef } from 'vue'
+import { createEventEmitter } from 'devframe/utils/events'
+import { markRaw, reactive, shallowRef, watch } from 'vue'
+
+export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage {
+ return {
+ mode: 'float',
+ width: 80,
+ height: 80,
+ top: 0,
+ left: 10,
+ position: 'bottom',
+ open: false,
+ inactiveTimeout: 3_000,
+ }
+}
+
+export function createDockEntryState(
+ entry: DevframeDockEntry,
+ selected: Ref,
+): DockEntryState {
+ const events = createEventEmitter()
+ const state: DockEntryState = reactive({
+ entryMeta: entry,
+ get isActive() {
+ return selected.value?.id === entry.id
+ },
+ domElements: {},
+ events: markRaw(events),
+ })
+
+ watch(
+ () => selected.value?.id,
+ (newSelectedId) => {
+ if (newSelectedId === entry.id) {
+ events.emit('entry:activated')
+ }
+ else {
+ events.emit('entry:deactivated')
+ }
+ },
+ { immediate: true },
+ )
+
+ watch(
+ () => state.domElements.iframe,
+ (newIframe) => {
+ if (newIframe)
+ events.emit('dom:iframe:mounted', newIframe)
+ },
+ { immediate: true },
+ )
+
+ watch(
+ () => state.domElements.panel,
+ (newPanel) => {
+ if (newPanel)
+ events.emit('dom:panel:mounted', newPanel)
+ },
+ { immediate: true },
+ )
+
+ return state
+}
+
+export function sharedStateToRef(sharedState: SharedState): ShallowRef {
+ const ref = shallowRef(sharedState.value() as T)
+ sharedState.on('updated', (newState: T) => {
+ ref.value = newState
+ })
+ return ref
+}
+
+const docksEntriesRefByRpc = new WeakMap>()
+export async function useDocksEntries(rpc: DevframeRpcClient): Promise[> {
+ if (docksEntriesRefByRpc.has(rpc)) {
+ return docksEntriesRefByRpc.get(rpc)!
+ }
+ const state = await rpc.sharedState.get('devframe:docks', { initialValue: [] })
+ const docksEntriesRef = sharedStateToRef(state)
+ docksEntriesRefByRpc.set(rpc, docksEntriesRef)
+ return docksEntriesRef
+}
diff --git a/packages/hub-ui/src/client/state/floating-tooltip.ts b/packages/hub-ui/src/client/state/floating-tooltip.ts
new file mode 100644
index 00000000..13eee866
--- /dev/null
+++ b/packages/hub-ui/src/client/state/floating-tooltip.ts
@@ -0,0 +1,72 @@
+import type { VNode } from 'vue'
+import { shallowRef } from 'vue'
+
+export interface FloatingPopoverProps {
+ el: HTMLElement
+ content: string | (() => VNode | undefined)
+ gap?: number
+ placement?: 'top' | 'bottom' | 'left' | 'right'
+}
+
+const tooltip = shallowRef(null)
+const docksOverflowPanel = shallowRef(null)
+const docksSidebarOverflowPanel = shallowRef(null)
+const dockContextMenu = shallowRef(null)
+
+export function setFloatingTooltip(info: FloatingPopoverProps | null) {
+ tooltip.value = info
+}
+
+export function useFloatingTooltip() {
+ return tooltip
+}
+
+export function setDocksOverflowPanel(info: FloatingPopoverProps | null) {
+ docksOverflowPanel.value = info
+}
+
+export function useDocksOverflowPanel() {
+ return docksOverflowPanel
+}
+
+/**
+ * Dedicated slot for the group side nav's height-based "show more" flyout,
+ * kept separate from {@link docksOverflowPanel} (the dock bar's overflow) and
+ * {@link docksGroupPanel} (the bar group button's popover) so the rail's
+ * show-more never fights those features over one shared ref.
+ */
+export function setDocksSidebarOverflowPanel(info: FloatingPopoverProps | null) {
+ docksSidebarOverflowPanel.value = info
+}
+
+export function useDocksSidebarOverflowPanel() {
+ return docksSidebarOverflowPanel
+}
+
+const docksGroupPanel = shallowRef(null)
+
+export function setDocksGroupPanel(info: FloatingPopoverProps | null) {
+ docksGroupPanel.value = info
+}
+
+export function useDocksGroupPanel() {
+ return docksGroupPanel
+}
+
+export function setDockContextMenu(info: FloatingPopoverProps | null) {
+ dockContextMenu.value = info
+}
+
+export function useDockContextMenu() {
+ return dockContextMenu
+}
+
+const edgePositionDropdown = shallowRef(null)
+
+export function setEdgePositionDropdown(info: FloatingPopoverProps | null) {
+ edgePositionDropdown.value = info
+}
+
+export function useEdgePositionDropdown() {
+ return edgePositionDropdown
+}
diff --git a/packages/hub-ui/src/client/state/keybindings.ts b/packages/hub-ui/src/client/state/keybindings.ts
new file mode 100644
index 00000000..f7cf0605
--- /dev/null
+++ b/packages/hub-ui/src/client/state/keybindings.ts
@@ -0,0 +1,131 @@
+import type { DevframeCommandEntry, DevframeCommandKeybinding } from '@devframes/hub'
+import type { WhenContext } from 'devframe/utils/when'
+import { evaluateWhen } from 'devframe/utils/when'
+
+export type { WhenContext } from 'devframe/utils/when'
+
+export const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform ?? '')
+
+export function formatKeybinding(key: string): string[] {
+ return key.split('+').map((part) => {
+ if (part === 'Mod')
+ return isMac ? '\u2318' : 'Ctrl'
+ if (part === 'Shift')
+ return isMac ? '\u21E7' : 'Shift'
+ if (part === 'Alt')
+ return isMac ? '\u2325' : 'Alt'
+ return part
+ })
+}
+
+export function normalizeKeyEvent(e: KeyboardEvent): string {
+ const parts: string[] = []
+ if (isMac ? e.metaKey : e.ctrlKey)
+ parts.push('Mod')
+ if (isMac ? e.ctrlKey : e.metaKey)
+ parts.push(isMac ? 'Ctrl' : 'Meta')
+ if (e.altKey)
+ parts.push('Alt')
+ if (e.shiftKey)
+ parts.push('Shift')
+
+ // Normalize key name
+ let key = e.key
+ if (key.length === 1)
+ key = key.toUpperCase()
+
+ // Don't add modifier keys as the main key
+ if (!['Control', 'Meta', 'Alt', 'Shift'].includes(key))
+ parts.push(key)
+
+ return parts.join('+')
+}
+
+function areKeybindingsEqual(
+ left: DevframeCommandKeybinding[] | undefined,
+ right: DevframeCommandKeybinding[] | undefined,
+): boolean {
+ const leftBindings = left ?? []
+ const rightBindings = right ?? []
+ return leftBindings.length === rightBindings.length
+ && leftBindings.every((binding, index) => binding.key === rightBindings[index]?.key)
+}
+
+export function isKeybindingOverrideDifferentFromDefault(
+ override: DevframeCommandKeybinding[] | undefined,
+ defaults: DevframeCommandKeybinding[] | undefined,
+): boolean {
+ return override !== undefined && !areKeybindingsEqual(override, defaults)
+}
+
+/**
+ * Drop the commands whose `when` clause does not hold in the current context,
+ * children included — `when` is documented to control palette visibility, but
+ * nothing evaluated it for nested entries.
+ *
+ * A parent that survives is shallow-cloned so its `children` can be narrowed
+ * without mutating the registry. Callers therefore get fresh parent objects on
+ * every call: match entries by `id`, never by reference.
+ */
+export function filterCommandsByWhen(
+ commands: DevframeCommandEntry[],
+ ctx: WhenContext,
+): DevframeCommandEntry[] {
+ const isAvailable = (cmd: { when?: string }) => !cmd.when || evaluateWhen(cmd.when, ctx)
+
+ const result: DevframeCommandEntry[] = []
+ for (const cmd of commands) {
+ if (!isAvailable(cmd))
+ continue
+ if (!cmd.children) {
+ result.push(cmd)
+ continue
+ }
+ // `children` is typed `Server[] | Client[]` rather than `(Server | Client)[]`,
+ // so filtering it in place widens the element type — same cast the other
+ // child-walking call sites use.
+ const children = (cmd.children as DevframeCommandEntry[]).filter(isAvailable)
+ result.push({ ...cmd, children } as DevframeCommandEntry)
+ }
+ return result
+}
+
+export function collectAllKeybindings(
+ commands: { value: DevframeCommandEntry[] },
+ getKeybindings: (id: string) => DevframeCommandKeybinding[],
+): Array<{ id: string, keybinding: DevframeCommandKeybinding }> {
+ const result: Array<{ id: string, keybinding: DevframeCommandKeybinding }> = []
+
+ for (const cmd of commands.value) {
+ for (const kb of getKeybindings(cmd.id)) {
+ result.push({ id: cmd.id, keybinding: kb })
+ }
+ // Also collect from children
+ if (cmd.children) {
+ for (const child of cmd.children) {
+ for (const kb of getKeybindings(child.id)) {
+ result.push({ id: child.id, keybinding: kb })
+ }
+ }
+ }
+ }
+
+ return result
+}
+
+export const KNOWN_BROWSER_SHORTCUTS: Record = {
+ 'Mod+T': 'Open new tab',
+ 'Mod+W': 'Close tab',
+ 'Mod+N': 'Open new window',
+ 'Mod+L': 'Focus address bar',
+ 'Mod+D': 'Bookmark page',
+ 'Mod+Q': 'Quit browser',
+ 'Mod+Shift+T': 'Reopen closed tab',
+ 'Mod+Shift+N': 'Open incognito window',
+ 'Mod+Shift+W': 'Close window',
+ 'Mod+Shift+Q': 'Quit browser (Chrome)',
+ 'Alt+F4': 'Close window (Windows)',
+ 'Mod+R': 'Reload page',
+ 'Mod+Shift+R': 'Hard reload page',
+ 'Mod+F': 'Find in page',
+}
diff --git a/packages/hub-ui/src/client/state/messages-client.ts b/packages/hub-ui/src/client/state/messages-client.ts
new file mode 100644
index 00000000..927a0e5f
--- /dev/null
+++ b/packages/hub-ui/src/client/state/messages-client.ts
@@ -0,0 +1,77 @@
+import type { DevframeMessageEntryInput, DevframeMessageHandle, DevframeMessagesClient } from '@devframes/hub'
+import type { DevframeRpcClient } from '@devframes/hub/client'
+
+export function createClientMessagesClient(rpc: DevframeRpcClient): DevframeMessagesClient {
+ const buffer: (() => Promise)[] = []
+ let flushing: Promise | undefined
+
+ async function flush() {
+ if (rpc.isTrusted !== true)
+ return
+ if (flushing === undefined) {
+ // eslint-disable-next-line no-async-promise-executor
+ flushing = new Promise(async (resolve) => {
+ while (buffer.length > 0) {
+ const op = buffer.shift()!
+ await op()
+ }
+ resolve()
+ })
+ }
+ return flushing
+ }
+
+ async function enqueue(op: () => Promise): Promise {
+ if (rpc.isTrusted === true && buffer.length !== 0)
+ await flush()
+
+ if (rpc.isTrusted === true && buffer.length === 0)
+ return await op()
+
+ return new Promise((resolve) => {
+ buffer.push(async () => {
+ const result = await op()
+ resolve(result)
+ })
+ })
+ }
+
+ rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
+ if (isTrusted && buffer.length > 0)
+ flush()
+ })
+
+ const client: DevframeMessagesClient = {
+ add(input: DevframeMessageEntryInput): Promise {
+ return enqueue(async () => {
+ let entry = await rpc.call('hub:messages:add', input)
+ return {
+ get entry() { return entry },
+ get id() { return entry.id },
+ async update(patch: Partial) {
+ const updated = await rpc.call('hub:messages:update', entry.id, patch)
+ if (updated)
+ entry = updated
+ return updated ?? undefined
+ },
+ async dismiss() {
+ await rpc.call('hub:messages:remove', entry.id)
+ },
+ }
+ })
+ },
+ remove(id: string): Promise {
+ return enqueue(() => rpc.call('hub:messages:remove', id))
+ },
+ clear(): Promise {
+ return enqueue(() => rpc.call('hub:messages:clear'))
+ },
+ // Level shortcuts — `messages.info('...')` is `add({ message, level: 'info', ...extra })`.
+ info: (message, extra) => client.add({ ...extra, message, level: 'info' }),
+ warn: (message, extra) => client.add({ ...extra, message, level: 'warn' }),
+ error: (message, extra) => client.add({ ...extra, message, level: 'error' }),
+ success: (message, extra) => client.add({ ...extra, message, level: 'success' }),
+ debug: (message, extra) => client.add({ ...extra, message, level: 'debug' }),
+ }
+ return client
+}
diff --git a/packages/hub-ui/src/client/state/messages.ts b/packages/hub-ui/src/client/state/messages.ts
new file mode 100644
index 00000000..818632b6
--- /dev/null
+++ b/packages/hub-ui/src/client/state/messages.ts
@@ -0,0 +1,94 @@
+import type { DevframeMessageEntry, DevframeMessagesListDelta, DevframeRpcClientFunctions } from '@devframes/hub'
+import type { DocksContext } from '@devframes/hub/client'
+import type { Reactive } from 'vue'
+import { reactive } from 'vue'
+import { addToast } from './toasts'
+
+export interface MessagesState {
+ entries: DevframeMessageEntry[]
+ unreadCount: number
+ pendingSelectId: string | null
+}
+
+let _messagesState: Reactive | undefined
+
+export function useMessages(context: DocksContext): Reactive {
+ if (_messagesState)
+ return _messagesState
+
+ const state: Reactive = _messagesState = reactive({
+ entries: [],
+ unreadCount: 0,
+ pendingSelectId: null,
+ })
+
+ const entryMap = new Map()
+ let isInitialFetch = true
+ let lastVersion: number | null = null
+
+ async function updateMessages() {
+ // The feed is served by `@devframes/plugin-messages` (the hub itself ships
+ // no list RPC). Cast because the plugin's RPC augmentation is not imported
+ // here — hub-ui must not depend on the plugin package.
+ const result = await context.rpc.call(
+ 'devframes:plugin:messages:list' as any,
+ lastVersion,
+ ) as DevframeMessagesListDelta
+ let newCount = 0
+
+ // A full snapshot resets any locally cached list before applying it.
+ if (result.full)
+ entryMap.clear()
+
+ // Apply removals
+ for (const id of result.removedIds)
+ entryMap.delete(id)
+
+ // Apply new/updated entries
+ for (const entry of result.entries) {
+ const prev = entryMap.get(entry.id)
+ if (!prev) {
+ newCount++
+ if (isInitialFetch) {
+ // On initial fetch (page refresh), only toast entries still loading
+ if (entry.notify && entry.status === 'loading')
+ addToast(entry)
+ }
+ else {
+ if (entry.notify)
+ addToast(entry)
+ }
+ }
+ else if (entry.notify && JSON.stringify(entry) !== JSON.stringify(prev)) {
+ addToast(entry)
+ }
+ entryMap.set(entry.id, entry)
+ }
+
+ state.entries = Array.from(entryMap.values())
+ state.unreadCount += newCount
+ lastVersion = result.version
+ isInitialFetch = false
+ }
+
+ // A hub without `@devframes/plugin-messages` serves no list RPC — degrade
+ // to an empty feed instead of surfacing unhandled rejections.
+ const refresh = () => updateMessages().catch(() => {})
+
+ context.rpc.client.register({
+ name: 'devframe:messages:updated' satisfies keyof DevframeRpcClientFunctions,
+ type: 'action',
+ handler: () => {
+ if (context.rpc.isTrusted)
+ refresh()
+ },
+ })
+
+ context.rpc.ensureTrusted().then(() => refresh())
+ return state
+}
+
+export function selectMessage(id: string): void {
+ if (_messagesState)
+ _messagesState.pendingSelectId = id
+}
diff --git a/packages/hub-ui/src/client/state/popup.ts b/packages/hub-ui/src/client/state/popup.ts
new file mode 100644
index 00000000..ba32c677
--- /dev/null
+++ b/packages/hub-ui/src/client/state/popup.ts
@@ -0,0 +1,245 @@
+import type { DocksContext } from '@devframes/hub/client'
+import { createEventEmitter } from 'devframe/utils/events'
+import { shallowRef, watch } from 'vue'
+import { isDark } from './color-mode'
+import { setDocksOverflowPanel } from './floating-tooltip'
+
+interface DocumentPictureInPicture {
+ requestWindow: (options?: { width?: number, height?: number }) => Promise
+}
+
+interface DockPopupEvents {
+ 'popup:open-requested': (context: DocksContext) => void
+}
+type MainFrameDockActionHandler = (entryId: string) => Promise | boolean
+
+const PANEL_MIN_SIZE = 20
+const PANEL_MAX_SIZE = 100
+const POPUP_MIN_WIDTH = 320
+const POPUP_MIN_HEIGHT = 240
+const MAIN_FRAME_ACTION_HANDLER_KEY = '__DEVFRAMES_TRIGGER_DOCK_ACTION__'
+const DEVFRAME_CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'
+
+const popupWindow = shallowRef(null)
+const isPopupOpen = shallowRef(false)
+const popupEvents = createEventEmitter()
+let detachPopupListeners: (() => void) | undefined
+let detachColorModeSync: (() => void) | undefined
+let popupDockElement: (HTMLElement & { remove: () => void }) | undefined
+let popupContext: DocksContext | undefined
+const loadDockStandalone: () => Promise HTMLElement> = async () => {
+ return await import('../components/DockStandalone').then(m => m.DockStandalone)
+}
+
+popupEvents.on('popup:open-requested', (context) => {
+ void openDockPopup(context)
+})
+
+function getDocumentPictureInPicture(): DocumentPictureInPicture | undefined {
+ if (typeof window === 'undefined')
+ return
+ return (window as Window & { documentPictureInPicture?: DocumentPictureInPicture }).documentPictureInPicture
+}
+
+function clearListeners() {
+ detachPopupListeners?.()
+ detachPopupListeners = undefined
+ detachColorModeSync?.()
+ detachColorModeSync = undefined
+}
+
+function setupPopupColorModeSync(popup: Window): () => void {
+ // Mirror the shared color-mode preference into the popup document's native
+ // `color-scheme` (scrollbars / form controls); the standalone shell inside
+ // handles its own `.dark`/`.light` class via `applyColorSchemeClass`.
+ return watch(
+ isDark,
+ (dark) => {
+ popup.document.documentElement?.style.setProperty('color-scheme', dark ? 'dark' : 'light')
+ },
+ { immediate: true },
+ )
+}
+
+function unmountPopupElement() {
+ popupDockElement?.remove()
+ popupDockElement = undefined
+}
+
+function clearPopupState() {
+ clearListeners()
+ unmountPopupElement()
+ popupWindow.value = null
+ isPopupOpen.value = false
+ const ctx = popupContext
+ popupContext = undefined
+ ctx?.docks.switchEntry(null)
+}
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max)
+}
+
+function syncPanelSizeFromPopup(context: DocksContext, popup: Window) {
+ if (window.innerWidth <= 0 || window.innerHeight <= 0)
+ return
+ context.panel.store.width = clamp(Math.round(popup.innerWidth / window.innerWidth * 100), PANEL_MIN_SIZE, PANEL_MAX_SIZE)
+ context.panel.store.height = clamp(Math.round(popup.innerHeight / window.innerHeight * 100), PANEL_MIN_SIZE, PANEL_MAX_SIZE)
+}
+
+async function mountStandaloneApp(context: DocksContext, popup: Window) {
+ const DockStandaloneElement = await loadDockStandalone()
+
+ const baseStyle = popup.document.createElement('style')
+ baseStyle.textContent = [
+ 'html, body {',
+ ' margin: 0;',
+ ' padding: 0;',
+ ' width: 100%;',
+ ' height: 100%;',
+ ' overflow: hidden;',
+ ' background: transparent;',
+ '}',
+ '#devframes-popup-root {',
+ ' width: 100vw;',
+ ' height: 100vh;',
+ '}',
+ '#devframes-popup-root > devframes-dock-standalone {',
+ ' display: block;',
+ ' width: 100%;',
+ ' height: 100%;',
+ '}',
+ ].join('\n')
+
+ popup.document.title = 'Devframes'
+ popup.document.head?.appendChild(baseStyle)
+ popup.document.body.textContent = ''
+
+ // Prefer the meta devframe's client already published on the main window
+ // (annotated with the absolute `baseUrl` it was resolved from) so devframe
+ // iframes mounted at other bases inside the popup inherit a dialable
+ // endpoint; fall back to the raw client meta.
+ ;(popup as Window & { [DEVFRAME_CONNECTION_META_KEY]?: unknown })[DEVFRAME_CONNECTION_META_KEY]
+ = (globalThis as Record)[DEVFRAME_CONNECTION_META_KEY] ?? context.rpc.connectionMeta
+
+ const appRoot = popup.document.createElement('div')
+ appRoot.id = 'devframes-popup-root'
+ popup.document.body.appendChild(appRoot)
+
+ const dockElement = new DockStandaloneElement({ context })
+ popupDockElement = dockElement
+ appRoot.appendChild(dockElement)
+}
+
+export function isDockPopupSupported(): boolean {
+ return !!getDocumentPictureInPicture()?.requestWindow
+}
+
+export function registerMainFrameDockActionHandler(
+ clientType: 'embedded' | 'standalone',
+ handler: MainFrameDockActionHandler,
+) {
+ if (typeof window === 'undefined') {
+ return
+ }
+ if (clientType === 'standalone') {
+ return
+ }
+ ;(window as Window & { [MAIN_FRAME_ACTION_HANDLER_KEY]?: MainFrameDockActionHandler })[MAIN_FRAME_ACTION_HANDLER_KEY] = handler
+}
+
+export async function triggerMainFrameDockAction(
+ clientType: 'embedded' | 'standalone',
+ entryId: string,
+): Promise {
+ if (typeof window === 'undefined')
+ return undefined
+ if (clientType !== 'standalone')
+ return undefined
+
+ try {
+ const opener = window.opener as (Window & { [MAIN_FRAME_ACTION_HANDLER_KEY]?: MainFrameDockActionHandler }) | null
+ if (!opener || opener.closed)
+ return undefined
+ const handler = opener[MAIN_FRAME_ACTION_HANDLER_KEY]
+ if (typeof handler !== 'function')
+ return undefined
+ return await handler(entryId)
+ }
+ catch {
+ return undefined
+ }
+}
+
+export function useDockPopupWindow() {
+ return popupWindow as Readonly
+}
+
+export function useIsDockPopupOpen() {
+ return isPopupOpen as Readonly
+}
+
+export function requestDockPopupOpen(context: DocksContext) {
+ popupEvents.emit('popup:open-requested', context)
+}
+
+export function closeDockPopup() {
+ const popup = popupWindow.value
+ clearPopupState()
+ if (!popup || popup.closed)
+ return
+ popup.close()
+}
+
+async function openDockPopup(context: DocksContext): Promise {
+ setDocksOverflowPanel(null)
+
+ const currentPopup = popupWindow.value
+ if (currentPopup?.closed) {
+ clearPopupState()
+ }
+ else if (currentPopup) {
+ currentPopup.focus()
+ return currentPopup
+ }
+
+ const documentPictureInPicture = getDocumentPictureInPicture()
+ if (!documentPictureInPicture?.requestWindow)
+ return null
+
+ let openedPopup: Window | undefined
+ try {
+ const popup = openedPopup = await documentPictureInPicture.requestWindow({
+ width: Math.max(POPUP_MIN_WIDTH, Math.round(window.innerWidth * context.panel.store.width / 100)),
+ height: Math.max(POPUP_MIN_HEIGHT, Math.round(window.innerHeight * context.panel.store.height / 100)),
+ })
+
+ await mountStandaloneApp(context, popup)
+ detachColorModeSync = setupPopupColorModeSync(popup)
+
+ const onResize = () => syncPanelSizeFromPopup(context, popup)
+ const onPageHide = () => {
+ if (popupWindow.value !== popup)
+ return
+ clearPopupState()
+ }
+
+ popup.addEventListener('resize', onResize)
+ popup.addEventListener('pagehide', onPageHide)
+ detachPopupListeners = () => {
+ popup.removeEventListener('resize', onResize)
+ popup.removeEventListener('pagehide', onPageHide)
+ }
+
+ popupContext = context
+ popupWindow.value = popup
+ isPopupOpen.value = true
+ return popup
+ }
+ catch {
+ if (openedPopup && !openedPopup.closed)
+ openedPopup.close()
+ clearPopupState()
+ return null
+ }
+}
diff --git a/packages/hub-ui/src/client/state/renderers.ts b/packages/hub-ui/src/client/state/renderers.ts
new file mode 100644
index 00000000..ad923a7f
--- /dev/null
+++ b/packages/hub-ui/src/client/state/renderers.ts
@@ -0,0 +1,51 @@
+import type { DevframeDockEntry } from '@devframes/hub'
+import type {
+ DevframeClientContext,
+ DockRenderer,
+ DockRenderersContext,
+ DocksContext,
+} from '@devframes/hub/client'
+
+/**
+ * The client-side dock-renderer registry required by the hub's `DocksContext`
+ * (devframe 0.7.6). The headless hub routes a dock `type` to a host-registered
+ * renderer through this registry — e.g. a host could register
+ * `@devframes/json-render-ui` for `'json-render'`.
+ *
+ * Devframes renders its built-in dock types directly in `ViewEntry.vue`
+ * (it owns its Vue shell rather than delegating to the hub's client host), so
+ * nothing is registered here by default. The registry still exists so the
+ * context satisfies the hub contract and third-party dock types can plug in.
+ */
+export function createDockRenderers(getContext: () => DocksContext): DockRenderersContext {
+ const renderers = new Map()
+
+ return {
+ register(type, renderer) {
+ renderers.set(type, renderer)
+ return () => {
+ if (renderers.get(type) === renderer)
+ renderers.delete(type)
+ }
+ },
+ get(type) {
+ return renderers.get(type)
+ },
+ has(type) {
+ return renderers.has(type)
+ },
+ async mount(entry: DevframeDockEntry, container: HTMLElement) {
+ const renderer = renderers.get(entry.type)
+ if (!renderer) {
+ console.warn(`[@devframes/hub-ui] no dock renderer registered for type "${entry.type}"`)
+ return () => {}
+ }
+ const instance = await renderer({
+ entry,
+ container,
+ context: getContext() as DevframeClientContext,
+ })
+ return () => instance.dispose?.()
+ },
+ }
+}
diff --git a/packages/hub-ui/src/client/state/setup-script.ts b/packages/hub-ui/src/client/state/setup-script.ts
new file mode 100644
index 00000000..19337c5b
--- /dev/null
+++ b/packages/hub-ui/src/client/state/setup-script.ts
@@ -0,0 +1,57 @@
+import type { ClientScriptEntry, DevframeDockUserEntry } from '@devframes/hub'
+import type { DockClientScriptContext } from '@devframes/hub/client'
+
+/**
+ * Resolve the {@link ClientScriptEntry} a dock entry carries — an `action`'s
+ * `action`, a `custom-render`'s `renderer`, or an iframe's `clientScript`.
+ */
+function clientScriptOf(entry: DevframeDockUserEntry): ClientScriptEntry | undefined {
+ switch (entry.type) {
+ case 'action':
+ return entry.action
+ case 'custom-render':
+ return entry.renderer
+ case 'iframe':
+ return entry.clientScript
+ default:
+ return undefined
+ }
+}
+
+async function _executeSetupScript(
+ entry: DevframeDockUserEntry,
+ context: DockClientScriptContext,
+): Promise {
+ const script = clientScriptOf(entry)
+ if (!script?.importFrom)
+ throw new Error(`[@devframes/hub-ui] Dock entry "${entry.id}" carries no client script to run`)
+ try {
+ // Keep this a *native* dynamic import in every bundler — the specifier is
+ // a runtime URL served by the hub, not a build-time module. Mirrors the
+ // client-script loading of `@devframes/hub`'s `createDevframeClientHost`.
+ const mod = await import(/* @vite-ignore */ /* webpackIgnore: true */ script.importFrom)
+ const fn = mod[script.importName ?? 'default']
+ if (typeof fn !== 'function')
+ throw new Error(`[@devframes/hub-ui] "${script.importFrom}" exports no callable "${script.importName ?? 'default'}"`)
+ await fn(context)
+ }
+ catch (error) {
+ // TODO: maybe popup a error toast here?
+ // TODO: A unified logger API
+ console.error('[@devframes/hub-ui] Error executing client script', error)
+ throw error
+ }
+}
+const _setupPromises = new Map>()
+export function executeSetupScript(
+ entry: DevframeDockUserEntry,
+ context: DockClientScriptContext,
+): Promise {
+ // Actions should re-execute on every click; only cache non-action scripts
+ if (entry.type !== 'action' && _setupPromises.has(entry.id))
+ return _setupPromises.get(entry.id)!
+ const promise = _executeSetupScript(entry, context)
+ if (entry.type !== 'action')
+ _setupPromises.set(entry.id, promise)
+ return promise
+}
diff --git a/packages/hub-ui/src/client/state/toasts.ts b/packages/hub-ui/src/client/state/toasts.ts
new file mode 100644
index 00000000..9c8ec24f
--- /dev/null
+++ b/packages/hub-ui/src/client/state/toasts.ts
@@ -0,0 +1,47 @@
+import type { DevframeMessageEntry } from '@devframes/hub'
+import type { Reactive } from 'vue'
+import { reactive } from 'vue'
+
+export interface ToastItem {
+ id: string
+ entry: DevframeMessageEntry
+}
+
+const toasts: Reactive = reactive([])
+const timers = new Map>()
+
+export function useToasts(): Reactive {
+ return toasts
+}
+
+export function addToast(entry: DevframeMessageEntry): void {
+ // Dedupe: update existing toast with same id
+ const existing = toasts.find(t => t.id === entry.id)
+ if (existing) {
+ existing.entry = entry
+ // Reset auto-dismiss timer
+ const timer = timers.get(entry.id)
+ if (timer)
+ clearTimeout(timer)
+ const timeout = entry.autoDismiss ?? 5000
+ timers.set(entry.id, setTimeout(dismissToast, timeout, entry.id))
+ return
+ }
+
+ const item: ToastItem = { id: entry.id, entry }
+ toasts.push(item)
+
+ const timeout = entry.autoDismiss ?? 5000
+ timers.set(entry.id, setTimeout(dismissToast, timeout, entry.id))
+}
+
+export function dismissToast(id: string): void {
+ const idx = toasts.findIndex(t => t.id === id)
+ if (idx !== -1)
+ toasts.splice(idx, 1)
+ const timer = timers.get(id)
+ if (timer) {
+ clearTimeout(timer)
+ timers.delete(id)
+ }
+}
diff --git a/packages/hub-ui/src/client/stories/fixtures.ts b/packages/hub-ui/src/client/stories/fixtures.ts
new file mode 100644
index 00000000..f553c389
--- /dev/null
+++ b/packages/hub-ui/src/client/stories/fixtures.ts
@@ -0,0 +1,112 @@
+import type { DevframeDockEntry, DevframeMessageEntry, DevframeViewGroup } from '@devframes/hub'
+
+type Extra = Partial
+
+/** An iframe dock entry (the common case). */
+export function iframe(id: string, title: string, icon: string, extra: Extra = {}): DevframeDockEntry {
+ return { id, type: 'iframe', url: '/', title, icon, ...extra } as DevframeDockEntry
+}
+
+/** An action dock entry — a one-shot command with no panel of its own. */
+function action(id: string, title: string, icon: string, extra: Extra = {}): DevframeDockEntry {
+ return { id, type: 'action', title, icon, ...extra } as DevframeDockEntry
+}
+
+/** A group entry — a collapse button whose members declare `groupId: id`. */
+export function group(id: string, title: string, icon: string, extra: Partial = {}): DevframeDockEntry {
+ return { id, type: 'group', title, icon, ...extra } as DevframeDockEntry
+}
+
+/**
+ * A small, single-category bar: a few top-level tools, no groups.
+ */
+export const basicEntries: DevframeDockEntry[] = [
+ iframe('overview', 'Overview', 'ph:gauge-duotone'),
+ iframe('inspect', 'Inspect', 'ph:stethoscope-duotone'),
+ iframe('assets', 'Assets', 'ph:images-duotone'),
+]
+
+/**
+ * A bar spanning several categories, so separators and category ordering show.
+ */
+export const categorizedEntries: DevframeDockEntry[] = [
+ iframe('overview', 'Overview', 'ph:gauge-duotone', { category: 'app' }),
+ iframe('routes', 'Routes', 'ph:signpost-duotone', { category: 'app' }),
+ iframe('components', 'Components', 'ph:puzzle-piece-duotone', { category: 'framework' }),
+ iframe('modules', 'Modules', 'ph:plugs-connected-duotone', { category: 'framework' }),
+ iframe('network', 'Network', 'ph:wifi-high-duotone', { category: 'web' }),
+ iframe('graph', 'Graph', 'ph:graph-duotone', { category: 'advanced' }),
+]
+
+/**
+ * Groups: a Nuxt umbrella (with a `defaultChildId`) and a Playground umbrella
+ * (popover-only), alongside a couple of top-level tools and an action.
+ */
+export const groupedEntries: DevframeDockEntry[] = [
+ iframe('overview', 'Overview', 'ph:gauge-duotone'),
+ action('reload', 'Reload', 'ph:arrow-clockwise-duotone'),
+
+ group('nuxt', 'Nuxt', 'logos:nuxt-icon', { category: 'framework', defaultChildId: 'nuxt:overview' }),
+ iframe('nuxt:overview', 'Overview', 'ph:gauge-duotone', { groupId: 'nuxt', defaultOrder: 0 }),
+ iframe('nuxt:pages', 'Pages', 'ph:files-duotone', { groupId: 'nuxt', defaultOrder: 1 }),
+ iframe('nuxt:components', 'Components', 'ph:puzzle-piece-duotone', { groupId: 'nuxt', defaultOrder: 2, badge: '3' }),
+ iframe('nuxt:modules', 'Modules', 'ph:plugs-connected-duotone', { groupId: 'nuxt', defaultOrder: 3 }),
+
+ group('playground', 'Playground', 'ph:flask-duotone', { category: 'framework' }),
+ iframe('playground:one', 'One', 'ph:number-circle-one-duotone', { groupId: 'playground' }),
+ iframe('playground:two', 'Two', 'ph:number-circle-two-duotone', { groupId: 'playground' }),
+]
+
+/**
+ * A single group whose members carry in-group sub-categories, so the popover /
+ * sidebar / settings show sub-category dividers and headers. The group's own
+ * `category` (`framework`) is the outer bucket; members split by their own
+ * `category` inside the group.
+ */
+export const subcategorizedGroupEntries: DevframeDockEntry[] = [
+ group('tools', 'Tools', 'ph:wrench-duotone', { category: 'framework' }),
+ iframe('tools:overview', 'Overview', 'ph:gauge-duotone', { groupId: 'tools', category: 'app', defaultOrder: 0 }),
+ iframe('tools:routes', 'Routes', 'ph:signpost-duotone', { groupId: 'tools', category: 'app', defaultOrder: 1 }),
+ iframe('tools:components', 'Components', 'ph:puzzle-piece-duotone', { groupId: 'tools', category: 'framework', defaultOrder: 2 }),
+ iframe('tools:graph', 'Graph', 'ph:graph-duotone', { groupId: 'tools', category: 'advanced', defaultOrder: 3 }),
+]
+
+/** The Tools group entry on its own (for sub-category group stories). */
+export const toolsGroup = subcategorizedGroupEntries.find(e => e.id === 'tools') as DevframeViewGroup
+
+/**
+ * Enough top-level entries to exceed the dock's 5-slot capacity, so the overflow
+ * button appears.
+ */
+export const overflowEntries: DevframeDockEntry[] = [
+ iframe('overview', 'Overview', 'ph:gauge-duotone'),
+ iframe('routes', 'Routes', 'ph:signpost-duotone'),
+ iframe('components', 'Components', 'ph:puzzle-piece-duotone'),
+ iframe('modules', 'Modules', 'ph:plugs-connected-duotone'),
+ iframe('assets', 'Assets', 'ph:images-duotone'),
+ iframe('network', 'Network', 'ph:wifi-high-duotone'),
+ iframe('timeline', 'Timeline', 'ph:chart-line-duotone'),
+ iframe('graph', 'Graph', 'ph:graph-duotone'),
+ iframe('terminal', 'Terminal', 'ph:terminal-window-duotone'),
+]
+
+/** A message/log entry for the messages panel and toasts. */
+export function message(extra: Partial = {}): DevframeMessageEntry {
+ return {
+ id: `msg-${Math.random().toString(36).slice(2, 8)}`,
+ message: 'Something happened',
+ level: 'info',
+ from: 'server',
+ timestamp: Date.now(),
+ ...extra,
+ } as DevframeMessageEntry
+}
+
+/** One message per level, covering the common shapes. */
+export const sampleMessages: DevframeMessageEntry[] = [
+ message({ level: 'error', message: 'Failed to resolve import "./missing"', description: 'in src/main.ts', category: 'runtime', labels: ['vite'] }),
+ message({ level: 'warn', message: 'Large chunk detected (612 kB)', category: 'build' }),
+ message({ level: 'success', message: 'HMR update applied', category: 'hmr' }),
+ message({ level: 'info', message: 'Server restarted', description: 'config change detected', category: 'server' }),
+ message({ level: 'warn', message: 'Checking accessibility…', status: 'loading', category: 'a11y' }),
+]
diff --git a/packages/hub-ui/src/client/stories/mock-context.ts b/packages/hub-ui/src/client/stories/mock-context.ts
new file mode 100644
index 00000000..0610f32e
--- /dev/null
+++ b/packages/hub-ui/src/client/stories/mock-context.ts
@@ -0,0 +1,127 @@
+import type { DevframeDockEntry } from '@devframes/hub'
+import type { DevframeRpcClient, DockPanelStorage, DocksContext, RpcClientEvents } from '@devframes/hub/client'
+import type { HubDocksUserSettings } from '../types'
+import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants'
+import { createEventEmitter } from 'devframe/utils/events'
+import { createSharedState } from 'devframe/utils/shared-state'
+import { ref } from 'vue'
+import { createDocksContext } from '../state/context'
+import { DEFAULT_DOCK_PANEL_STORE } from '../state/docks'
+
+/**
+ * A story-only RPC client. Backs the three shared-state keys the dock context
+ * reads (`devframe:docks`, `devframe:user-settings`, `devframe:commands`) with
+ * in-memory stores, stubs `call`, and exposes a mutable `isTrusted` you can flip
+ * to exercise the unauthorized paths.
+ */
+export interface MockRpcClient extends DevframeRpcClient {
+ /** Flip the trust flag and emit `rpc:is-trusted:updated`, as the real WS client does. */
+ setTrusted: (value: boolean) => void
+}
+
+export interface CreateMockContextOptions {
+ /** Dock entries seeded into `devframe:docks`. */
+ entries?: DevframeDockEntry[]
+ /** Which client shell the context represents. */
+ clientType?: 'embedded' | 'standalone'
+ /** Overrides merged over the default panel store (mode, position, open, ...). */
+ panel?: Partial
+ /** Overrides merged over the default user settings (hidden, pinned, order, ...). */
+ settings?: Partial
+ /** Entry id to pre-select (also opens the panel). */
+ selectedId?: string | null
+ /** Initial trust state. Defaults to `true`. */
+ isTrusted?: boolean
+}
+
+function createMockRpc(
+ entries: DevframeDockEntry[],
+ settings: Partial,
+ initialTrusted: boolean,
+): MockRpcClient {
+ const docksState = createSharedState({ initialValue: entries, enablePatches: false })
+ const settingsState = createSharedState({
+ initialValue: { ...DEFAULT_STATE_USER_SETTINGS(), ...settings },
+ enablePatches: false,
+ })
+ const commandsState = createSharedState({ initialValue: [] as any[], enablePatches: false })
+ const events = createEventEmitter()
+
+ let trusted = initialTrusted
+
+ // Minimal RPC surface used by the composed shells (messages/toasts, command
+ // palette). `call` returns empty-but-valid shapes for the methods those call.
+ const call = async (method: string) => {
+ if (method === 'devframes:plugin:messages:list')
+ return { entries: [], removedIds: [], version: 0, full: true }
+ return undefined
+ }
+
+ const rpc = {
+ events,
+ get isTrusted() {
+ return trusted
+ },
+ ensureTrusted: async () => trusted === true,
+ requestTrust: async () => trusted === true,
+ requestTrustWithCode: async () => trusted === true,
+ requestTrustWithToken: async () => trusted === true,
+ call,
+ callEvent: async () => undefined,
+ callOptional: async () => undefined,
+ client: {
+ // Register a client-side RPC handler; returns an unsubscribe.
+ register: () => () => {},
+ },
+ sharedState: {
+ get: async (key: string) => {
+ if (key === 'devframe:docks')
+ return docksState
+ if (key === 'devframe:user-settings')
+ return settingsState
+ if (key === 'devframe:commands')
+ return commandsState
+ throw new Error(`[mock-rpc] Unexpected shared state key: ${key}`)
+ },
+ },
+ setTrusted(value: boolean) {
+ trusted = value
+ events.emit('rpc:is-trusted:updated', value)
+ },
+ }
+
+ return rpc as unknown as MockRpcClient
+}
+
+/**
+ * Build a real {@link DocksContext} wired to an in-memory {@link MockRpcClient}.
+ *
+ * This is the same `createDocksContext` the client boots with, so grouping,
+ * `switchEntry`, commands and when-clauses all behave exactly as they do at
+ * runtime — only the transport is faked. Every call gets a fresh RPC instance,
+ * so the per-rpc context cache never bleeds between stories.
+ */
+export async function createMockDocksContext(
+ options: CreateMockContextOptions = {},
+): Promise {
+ const {
+ entries = [],
+ clientType = 'embedded',
+ panel,
+ settings = {},
+ selectedId = null,
+ isTrusted = true,
+ } = options
+
+ const rpc = createMockRpc(entries, settings, isTrusted)
+ const panelStore = ref({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel })
+
+ const context = await createDocksContext(clientType, rpc, panelStore)
+
+ if (selectedId != null) {
+ context.docks.selectedId = selectedId
+ context.panel.store.open = true
+ }
+
+ return context as DocksContext & { rpc: MockRpcClient }
+}
diff --git a/packages/hub-ui/src/client/stories/story-helpers.ts b/packages/hub-ui/src/client/stories/story-helpers.ts
new file mode 100644
index 00000000..77f73868
--- /dev/null
+++ b/packages/hub-ui/src/client/stories/story-helpers.ts
@@ -0,0 +1,58 @@
+import type { DocksContext } from '@devframes/hub/client'
+import type { CreateMockContextOptions } from './mock-context'
+import { defineComponent, h, Suspense } from 'vue'
+import { createMockDocksContext } from './mock-context'
+
+/**
+ * Render a component tree that needs a live {@link DocksContext}.
+ *
+ * `createMockDocksContext` is async (it boots the real dock context over a mock
+ * transport), so we resolve it inside an async `setup` wrapped in ``.
+ * The returned value is a render function — drop it straight into a story's
+ * `setup`:
+ *
+ * ```ts
+ * export const Example: Story = {
+ * render: () => ({
+ * setup: () => mountWithContext(
+ * { entries: groupedEntries },
+ * ctx => h(SomeComponent, { context: ctx }),
+ * ),
+ * }),
+ * }
+ * ```
+ */
+export function mountWithContext(
+ options: CreateMockContextOptions,
+ slot: (context: DocksContext) => unknown,
+): () => unknown {
+ const Inner = defineComponent({
+ name: 'MockContextProvider',
+ async setup() {
+ const context = await createMockDocksContext(options)
+ return () => slot(context) as any
+ },
+ })
+
+ return () => h(
+ Suspense,
+ null,
+ {
+ default: () => h(Inner),
+ fallback: () => h('div', { class: 'p8 op50 font-sans' }, 'Loading…'),
+ },
+ )
+}
+
+/**
+ * Wrap story content in a centered, padded stage. Handy for the smaller
+ * presentational pieces (entries, popovers, sidebars) that would otherwise sit
+ * flush against the canvas edge.
+ */
+export function stage(children: unknown, extraClass = ''): unknown {
+ return h(
+ 'div',
+ { class: `flex items-center justify-center p10 min-h-60 ${extraClass}` },
+ children as any,
+ )
+}
diff --git a/packages/hub-ui/src/client/style.css b/packages/hub-ui/src/client/style.css
new file mode 100644
index 00000000..e62d3d2d
--- /dev/null
+++ b/packages/hub-ui/src/client/style.css
@@ -0,0 +1,135 @@
+:root {
+ --un-text-opacity: 100%;
+}
+
+/*
+ * Thin/transparent scrollbar, matching packages/ui/src/styles/global.css —
+ * that stylesheet only reaches the Nuxt plugin apps, not this injected
+ * webcomponents shell, which otherwise falls back to the browser default
+ * (thick, opaque-gutter) scrollbar clashing with the translucent dock.
+ * `:host`, not `:root` — this stylesheet is adopted into the dock's shadow
+ * root (`defineCustomElement(..., { shadowRoot: true, styles: [css] })`),
+ * where `:root` only ever matches the top-level document's .
+ * Rules below are unscoped (not `#devframes-anchor ...`) since the
+ * shadow boundary already keeps them from leaking onto the host page, and
+ * scoping under the anchor's id would miss edge-docked mode entirely
+ * (`DockEmbedded.vue` renders `DockEdge`/`#devframes-edge-panel` and
+ * `Dock`/`#devframes-anchor` as mutually exclusive siblings).
+ */
+:host {
+ --app-scrollbar-size: 6px;
+ --app-scrollbar-radius: 1px;
+ --app-scrollbar-thumb: #8884;
+ --app-scrollbar-thumb-hover: #8885;
+}
+
+::-webkit-scrollbar {
+ width: var(--app-scrollbar-size);
+}
+
+::-webkit-scrollbar:horizontal {
+ height: var(--app-scrollbar-size);
+}
+
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: var(--app-scrollbar-thumb);
+ transition: background 0.2s ease;
+ border-radius: var(--app-scrollbar-radius);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: var(--app-scrollbar-thumb-hover);
+}
+
+::-webkit-scrollbar-track {
+ border-radius: var(--app-scrollbar-radius);
+ background: transparent;
+}
+
+#devframes-anchor {
+ --uno: w-0 z-floating-anchor fixed origin-center font-sans text-[15px] box-border;
+ transform: translate(-50%, -50%) rotate(0);
+}
+
+#devframes-anchor #devframes-dock-container {
+ --uno: absolute left-0 top-0 w-max flex;
+ height: var(--devframes-dock-height, 40px);
+ min-width: var(--devframes-dock-min-width, 100px);
+ transform: translate(-50%, -50%);
+}
+
+#devframes-anchor.devframes-vertical #devframes-dock-container {
+ transform: translate(-50%, -50%) rotate(90deg);
+ --uno: transition-all duration-500;
+}
+
+#devframes-anchor #devframes-dock {
+ --uno: h-full rounded-full select-none touch-none ma;
+ --uno: "shadow text-[#333] dark:text-white bg-glass:80";
+ --uno: transition-all duration-500;
+ height: var(--devframes-dock-height, 40px);
+ width: calc-size(max-content, size);
+}
+
+#devframes-anchor.devframes-minimized #devframes-dock {
+ --uno: px-0 py-[2px];
+ width: var(--devframes-dock-minimized-size, 22px);
+ height: var(--devframes-dock-minimized-size, 22px);
+}
+
+#devframes-anchor.devframes-minimized .devframes-dock-bracket {
+ --uno: w-1.5 op50;
+}
+
+#devframes-anchor:hover #devframes-glowing {
+ --uno: opacity-60;
+}
+
+#devframes-anchor #devframes-glowing {
+ --uno: absolute left-0 top-0 opacity-0 transition-all duration-[1000ms] ease-out pointer-events-none -z-[1] rounded-full;
+ width: var(--devframes-dock-glow-size, 160px);
+ height: var(--devframes-dock-glow-size, 160px);
+ filter: blur(var(--devframes-dock-glow-blur, 60px));
+ transform: translate(-50%, -50%);
+ background-image: linear-gradient(45deg, #61d9ff, #6b84fd, #715ebd);
+}
+
+@media print {
+ #devframes-anchor {
+ --uno: hidden;
+ }
+}
+
+.devframes-resize-handle-horizontal {
+ --uno: absolute left-[6px] right-[6px] h-[10px] -my-[5px] cursor-ns-resize rounded-md;
+}
+.devframes-resize-handle-vertical {
+ --uno: absolute top-[6px] bottom-0 w-[10px] -mx-[5px] cursor-ew-resize rounded-md;
+}
+.devframes-resize-handle-corner {
+ --uno: absolute w-[14px] h-[14px] -m-[6px] rounded-md;
+}
+.devframes-resize-handle {
+ --uno: z-viewframe-resizer;
+}
+.devframes-resize-handle:hover {
+ --uno: bg-gray-400:10;
+}
+
+@keyframes devframes-shake {
+ 0%, 100% { transform: translateX(0); }
+ 20%, 60% { transform: translateX(-5px); }
+ 40%, 80% { transform: translateX(5px); }
+}
+.devframes-shake {
+ animation: devframes-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97);
+}
+@media (prefers-reduced-motion: reduce) {
+ .devframes-shake {
+ animation: none;
+ }
+}
diff --git a/packages/hub-ui/src/client/types.ts b/packages/hub-ui/src/client/types.ts
new file mode 100644
index 00000000..444b0a89
--- /dev/null
+++ b/packages/hub-ui/src/client/types.ts
@@ -0,0 +1,76 @@
+import type { DevframeDockEntryBase, DevframeDocksUserSettings, DevframeViewLauncher, JsonRenderer } from '@devframes/hub/types'
+
+/**
+ * hub-ui user settings — the hub's {@link DevframeDocksUserSettings} widened
+ * with the toggles only this viewer ships.
+ *
+ * The extra fields are optional so the hub's `DEFAULT_STATE_USER_SETTINGS()`
+ * (which knows nothing about them) stays assignable; an absent value reads as
+ * its documented default.
+ */
+export interface HubDocksUserSettings extends DevframeDocksUserSettings {
+ /**
+ * Reveal the Devframe Inspector dock (the "devtools for the devtools"
+ * meta-introspection panel). Hidden by default — an absent value keeps the
+ * dock out of the dock bar until the user opts in from Settings → Advanced.
+ */
+ showDevframeInspector?: boolean
+}
+
+/**
+ * A `json-render` dock entry. `@devframes/hub` ships no json-render variant of
+ * its own (json-render is the opt-in `@devframes/json-render` package), so
+ * hub-ui contributes this entry to the hub's open dock union.
+ *
+ * It carries the {@link JsonRenderer} handle from the deprecated
+ * `ctx.createJsonRenderer()` on `ui`; the handle's methods are non-enumerable,
+ * so only its serializable metadata survives dock projection into shared
+ * state, where the client reads `ui._stateKey` to subscribe to the live spec.
+ */
+export interface DevframeViewJsonRender extends DevframeDockEntryBase {
+ type: 'json-render'
+ /** The renderer handle created by `ctx.createJsonRenderer()`. */
+ ui: JsonRenderer
+}
+
+declare module '@devframes/hub/types' {
+ interface DevframeDockEntryRegistry {
+ 'json-render': DevframeViewJsonRender
+ }
+}
+
+/**
+ * A selectable launch root offered by a launcher dock entry.
+ *
+ * When a launcher supplies {@link HubViewLauncher.launcher.roots}, the viewer
+ * renders a picker above the launch button. The selected root's
+ * {@link DevframeLaunchRoot.value} is forwarded to the launch as `{ root }`,
+ * where a process launcher uses it as the spawned process's `cwd`.
+ */
+interface DevframeLaunchRoot {
+ /** Absolute path forwarded as the spawn `cwd` when this root is selected. */
+ value: string
+ /** Human-friendly label shown in the picker (e.g. `Workspace root`). */
+ label: string
+ /** Optional secondary line, e.g. the path itself. */
+ description?: string
+}
+
+/**
+ * hub-ui augmentation of hub's launcher entry: adds optional selectable launch
+ * {@link HubViewLauncher.launcher.roots | roots}.
+ *
+ * Docks belong to `@devframes/hub`; this extends the upstream launcher shape
+ * locally until the field lands there. Since `roots` is optional, a plain hub
+ * `DevframeViewLauncher` remains assignable to this type.
+ */
+export interface HubViewLauncher extends DevframeViewLauncher {
+ launcher: DevframeViewLauncher['launcher'] & {
+ /**
+ * Selectable launch roots, owner-populated via `docks.update()`. When
+ * present the viewer renders a picker; the chosen root's `value` is
+ * forwarded to the bound launch command as a `{ root }` payload.
+ */
+ roots?: DevframeLaunchRoot[]
+ }
+}
diff --git a/packages/hub-ui/src/client/utils/accent-color.ts b/packages/hub-ui/src/client/utils/accent-color.ts
new file mode 100644
index 00000000..4eeb1279
--- /dev/null
+++ b/packages/hub-ui/src/client/utils/accent-color.ts
@@ -0,0 +1,24 @@
+/**
+ * Inline `:style` helpers for `DevframeViewGroup.accentColor` — an optional,
+ * arbitrary CSS color set by the group's owner. Since the value isn't known
+ * at build time it can't become a UnoCSS utility class (mirrors the
+ * `getHashColorFromString` → `HashBadge` pattern), so callers bind these as
+ * `:style` and keep their default Uno classes (e.g. `text-primary`,
+ * `bg-active`) as the fallback for when `accentColor` is unset — per the
+ * field's own doc comment.
+ */
+
+/** Text color only — for the group button / anchor icon. */
+export function accentTextStyle(color: string | undefined): { color: string } | undefined {
+ return color ? { color } : undefined
+}
+
+/** Text color plus a soft tinted background — for an active/selected row. */
+export function accentActiveStyle(color: string | undefined): { color: string, backgroundColor: string } | undefined {
+ if (!color)
+ return undefined
+ return {
+ color,
+ backgroundColor: `color-mix(in srgb, ${color} 15%, transparent)`,
+ }
+}
diff --git a/packages/hub-ui/src/client/utils/iconify.ts b/packages/hub-ui/src/client/utils/iconify.ts
new file mode 100644
index 00000000..95836658
--- /dev/null
+++ b/packages/hub-ui/src/client/utils/iconify.ts
@@ -0,0 +1,33 @@
+import createDOMPurify from 'dompurify'
+
+const getIconifySvgMap = new Map | string>()
+
+const purify = createDOMPurify()
+
+export async function getIconifySvg(collection: string, icon: string) {
+ const id = `${collection}:${icon}`
+ if (getIconifySvgMap.has(id)) {
+ return getIconifySvgMap.get(id)!
+ }
+ const promise = _get()
+ .then((svg) => {
+ getIconifySvgMap.set(id, svg)
+ return svg
+ })
+ .catch((err) => {
+ // Don't cache failures — drop the entry so a later render can retry.
+ getIconifySvgMap.delete(id)
+ throw err
+ })
+ getIconifySvgMap.set(id, promise)
+ return promise
+
+ async function _get() {
+ const url = `https://api.iconify.design/${collection}/${icon}.svg?color=currentColor&width=100%`
+ // Bound the request so a stalled connection (offline / flaky CDN / firewall
+ // black-holing the host) rejects instead of hanging forever — the caller
+ // already degrades a rejected fetch to a blank icon.
+ const svg = await fetch(url, { signal: AbortSignal.timeout(10_000) }).then(res => res.text())
+ return purify.sanitize(svg)
+ }
+}
diff --git a/packages/hub-ui/src/client/utils/useIframePanes.ts b/packages/hub-ui/src/client/utils/useIframePanes.ts
new file mode 100644
index 00000000..90e4cb80
--- /dev/null
+++ b/packages/hub-ui/src/client/utils/useIframePanes.ts
@@ -0,0 +1,83 @@
+import type { DevframeDockEntry } from '@devframes/hub'
+import type { DocksPanelContext } from '@devframes/hub/client'
+import type { IframePanes } from 'iframe-pane'
+import type { ShallowRef } from 'vue'
+import { createIframePanes } from 'iframe-pane'
+import { markRaw, onScopeDispose, shallowRef, watch } from 'vue'
+
+export function getEntryPaneKey(entry: DevframeDockEntry | null | undefined): string | null {
+ if (!entry)
+ return null
+ return entry.type === 'iframe' ? (entry.frameId ?? entry.id) : entry.id
+}
+
+/**
+ * Own an {@link IframePanes} manager for a dock shell, parking its persistent
+ * iframe/element panes in the given overlay container.
+ *
+ * The manager is created lazily once the container element exists (it is a
+ * template ref that starts empty), and torn down when the owning scope is
+ * disposed. When a `panel` context is supplied, its geometry is watched so
+ * panes stay glued to their targets during moves the shared `ResizeObserver`
+ * can't see, and pointer events on every pane are locked while the panel is
+ * dragged or resized so iframes don't swallow the interaction.
+ */
+export function useIframePanes(
+ container: Readonly>,
+ panel?: DocksPanelContext,
+ getActiveKey?: () => string | null,
+): Readonly> {
+ const panes = shallowRef()
+
+ const stop = watch(
+ container,
+ (el) => {
+ if (el && !panes.value) {
+ panes.value = markRaw(createIframePanes({ container: el, document: el.ownerDocument }))
+ stop()
+ }
+ },
+ { immediate: true, flush: 'post' },
+ )
+
+ if (getActiveKey) {
+ watch(
+ getActiveKey,
+ (activeKey) => {
+ panes.value?.list().forEach((pane) => {
+ if (pane.id !== activeKey)
+ pane.hide()
+ })
+ },
+ { flush: 'post' },
+ )
+ }
+
+ if (panel) {
+ // A panel move changes its box position without resizing the target, so the
+ // shared ResizeObserver/scroll listeners never fire — re-sync explicitly.
+ watch(() => panel, () => panes.value?.updateAll(), { deep: true })
+
+ // Iframes capture pointer events, which breaks dragging/resizing happening
+ // above them — lock them for the duration of the interaction.
+ let release: (() => void) | undefined
+ watch(
+ () => panel.isDragging || panel.isResizing,
+ (locked) => {
+ if (locked) {
+ release ??= panes.value?.lockPointerEvents()
+ }
+ else {
+ release?.()
+ release = undefined
+ }
+ },
+ { flush: 'sync' },
+ )
+ onScopeDispose(() => release?.())
+ }
+
+ onScopeDispose(() => panes.value?.dispose())
+
+ return panes
+}
diff --git a/packages/hub-ui/src/client/utils/useIsRpcTrusted.ts b/packages/hub-ui/src/client/utils/useIsRpcTrusted.ts
new file mode 100644
index 00000000..04317ad6
--- /dev/null
+++ b/packages/hub-ui/src/client/utils/useIsRpcTrusted.ts
@@ -0,0 +1,25 @@
+import type { DocksContext } from '@devframes/hub/client'
+import type { Ref } from 'vue'
+import { onScopeDispose, ref } from 'vue'
+
+/**
+ * Track the RPC client's trust flag as a reactive ref, seeded from the current
+ * value and kept in sync via `rpc:is-trusted:updated`.
+ *
+ * The subscription is torn down with the owning effect scope, so remounting a
+ * dock shell (as Storybook and tests do) never leaks listeners onto the shared
+ * RPC client. Pass `onChange` to react to transitions (e.g. close the panel on
+ * revocation).
+ */
+export function useIsRpcTrusted(
+ context: Pick,
+ onChange?: (isTrusted: boolean) => void,
+): Ref {
+ const isTrusted = ref(context.rpc.isTrusted)
+ const off = context.rpc.events.on('rpc:is-trusted:updated', (value) => {
+ isTrusted.value = value
+ onChange?.(value)
+ })
+ onScopeDispose(() => off())
+ return isTrusted
+}
diff --git a/packages/hub-ui/src/index.ts b/packages/hub-ui/src/index.ts
new file mode 100644
index 00000000..e4f003fa
--- /dev/null
+++ b/packages/hub-ui/src/index.ts
@@ -0,0 +1,56 @@
+import type { DevframeHubUi } from '@devframes/hub/initiate'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+/**
+ * The built client assets live next to the built entry (`dist/index.mjs` →
+ * `dist/client/`). When this module runs from source instead (tests and
+ * playgrounds resolving the workspace alias), fall back to the package's
+ * `dist/client/` — the assets are build artifacts either way.
+ */
+function clientDir(): string {
+ const here = fileURLToPath(new URL('.', import.meta.url))
+ const sibling = join(here, 'client')
+ // Probe for the built bootstrap, not the directory — running from source,
+ // `src/client/` exists too but holds sources, not servable artifacts.
+ if (existsSync(join(sibling, 'embedded.js')))
+ return sibling
+ return join(here, '../dist/client')
+}
+
+export interface CreateUiOptions {
+ /** Serve the standalone viewer SPA at the hub base. Default: `true`. */
+ viewer?: boolean
+ /** Serve the floating-dock bootstrap at `embedded.js`. Default: `true`. */
+ embedded?: boolean
+}
+
+/**
+ * The reference implementation of the hub's {@link DevframeHubUi} slot —
+ * prebuilt from this package's web components (the floating `DockEmbedded`
+ * bootstrap and the standalone `DockStandalone` viewer), styled with the
+ * shared devframe design system.
+ *
+ * ```ts
+ * import { initHub } from '@devframes/hub/initiate'
+ * import { createUi } from '@devframes/hub-ui'
+ *
+ * const hub = initHub({ devframes: [git, terminals], ui: createUi() })
+ * ```
+ *
+ * The hub stays headless either way — this object is one implementation of
+ * the slot; a viewer product (or your own) supplies a different one to the
+ * same option and reuses all the infrastructure.
+ */
+export function createUi(options: CreateUiOptions = {}): DevframeHubUi {
+ const client = clientDir()
+ return {
+ ...(options.viewer !== false
+ ? { viewer: { distDir: join(client, 'standalone') } }
+ : {}),
+ ...(options.embedded !== false
+ ? { embedded: { entry: join(client, 'embedded.js') } }
+ : {}),
+ }
+}
diff --git a/packages/hub-ui/tsconfig.json b/packages/hub-ui/tsconfig.json
new file mode 100644
index 00000000..aaf1e9d9
--- /dev/null
+++ b/packages/hub-ui/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "jsxImportSource": "vue",
+ "lib": ["esnext", "dom", "dom.iterable"],
+ "types": ["vite/client"]
+ },
+ "include": ["src", "scripts", "*.config.ts"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/packages/hub-ui/tsdown.config.ts b/packages/hub-ui/tsdown.config.ts
new file mode 100644
index 00000000..a17aed05
--- /dev/null
+++ b/packages/hub-ui/tsdown.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'tsdown'
+
+// Node-side entry only (`createUi()`); the browser bundles — the embedded
+// dock bootstrap and the standalone viewer — are built by the two Vite
+// configs (`vite.embedded.config.ts`, `src/client/standalone/vite.config.ts`).
+export default defineConfig({
+ entry: {
+ index: 'src/index.ts',
+ },
+ outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }),
+ clean: false,
+ tsconfig: '../../tsconfig.base.json',
+ dts: true,
+ platform: 'node',
+})
diff --git a/packages/hub-ui/uno.config.ts b/packages/hub-ui/uno.config.ts
new file mode 100644
index 00000000..833c09cf
--- /dev/null
+++ b/packages/hub-ui/uno.config.ts
@@ -0,0 +1,35 @@
+import { mergeConfigs } from 'unocss'
+import { designConfig } from '../../design/uno.config'
+
+// The shared devframe design base (see `design/uno.config.ts` and the root
+// AGENTS.md "Design system" section) plus this package's own extraction
+// globs and the dock-shell z-layers. The generated stylesheet is compiled
+// ahead of time by `scripts/build-css.ts` into
+// `src/client/.generated/css.ts` and injected into each custom element's
+// shadow root, so the components stay styled in any host page without a
+// global stylesheet.
+export default mergeConfigs([
+ designConfig,
+ {
+ content: {
+ pipeline: {
+ include: [/\.vue($|\?)/, /\.ts$/],
+ },
+ },
+ shortcuts: {
+ // Dock-shell z-layers (named — the design preset blocks plain
+ // `z-`). The floating shell layers sit at the very top of the
+ // host page's stacking order, mirroring the upstream dock.
+ 'z-viewframe': 'z-[20]',
+ 'z-viewframe-resizer': 'z-[30]',
+ 'z-floating-dock': 'z-[50]',
+ 'z-floating-anchor': 'z-[2147483644]',
+ 'z-floating-tooltip': 'z-[2147483645]',
+ 'z-command-palette': 'z-[2147483646]',
+ // The confirm modal and the toast overlay top the whole stack, above
+ // the palette.
+ 'z-dock-confirm': 'z-[2147483647]',
+ 'z-dock-toast': 'z-[2147483647]',
+ },
+ },
+])
diff --git a/packages/hub-ui/vite.embedded.config.ts b/packages/hub-ui/vite.embedded.config.ts
new file mode 100644
index 00000000..e6e8afca
--- /dev/null
+++ b/packages/hub-ui/vite.embedded.config.ts
@@ -0,0 +1,33 @@
+import { fileURLToPath } from 'node:url'
+import Vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vite'
+
+// The embedded floating-dock bootstrap — a single, self-contained ES module
+// the hub serves at `embedded.js` (see `DevframeHubUi.embedded`). One
+// file on purpose: the hub streams exactly one entry, so dynamic imports are
+// inlined and Vue rides inside the bundle. Styles live in the shadow root
+// (`.generated/css.ts`), so no CSS asset is emitted either.
+export default defineConfig({
+ plugins: [Vue()],
+ define: {
+ // Bundled for a host page: strip Vue's dev-mode branches.
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ '__VUE_OPTIONS_API__': 'false',
+ '__VUE_PROD_DEVTOOLS__': 'false',
+ '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
+ },
+ build: {
+ outDir: fileURLToPath(new URL('./dist/client', import.meta.url)),
+ emptyOutDir: false,
+ lib: {
+ entry: fileURLToPath(new URL('./src/client/embedded/index.ts', import.meta.url)),
+ formats: ['es'],
+ fileName: () => 'embedded.js',
+ },
+ rollupOptions: {
+ output: {
+ inlineDynamicImports: true,
+ },
+ },
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a1464d55..e0f2bca3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,6 +15,9 @@ catalogs:
'@vitejs/plugin-vue':
specifier: ^6.0.8
version: 6.0.8
+ magic-string:
+ specifier: ^0.30.21
+ version: 0.30.21
mlly:
specifier: ^1.8.2
version: 1.8.2
@@ -183,6 +186,9 @@ catalogs:
'@unocss/preset-icons':
specifier: ^66.7.5
version: 66.7.5
+ '@unocss/reset':
+ specifier: ^66.7.5
+ version: 66.7.5
'@vueuse/core':
specifier: ^14.4.0
version: 14.4.0
@@ -207,6 +213,9 @@ catalogs:
floating-vue:
specifier: ^5.2.2
version: 5.2.2
+ fuse.js:
+ specifier: ^7.1.0
+ version: 7.5.0
iframe-pane:
specifier: ^1.1.0
version: 1.1.0
@@ -937,6 +946,75 @@ importers:
specifier: catalog:deps
version: 1.4.2(typescript@6.0.3)
+ packages/hub-ui:
+ devDependencies:
+ '@devframes/hub':
+ specifier: workspace:*
+ version: link:../hub
+ '@iconify-json/ph':
+ specifier: catalog:frontend
+ version: 1.2.2
+ '@json-render/core':
+ specifier: catalog:deps
+ version: 0.19.0(zod@4.4.3)
+ '@json-render/vue':
+ specifier: catalog:frontend
+ version: 0.19.0(vue@3.5.40(typescript@5.9.3))(zod@4.4.3)
+ '@storybook/addon-docs':
+ specifier: catalog:storybook
+ version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))
+ '@storybook/vue3-vite':
+ specifier: catalog:storybook
+ version: 10.5.6(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
+ '@types/node':
+ specifier: catalog:types
+ version: 26.1.2
+ '@unocss/reset':
+ specifier: catalog:frontend
+ version: 66.7.5
+ '@vitejs/plugin-vue':
+ specifier: catalog:build
+ version: 6.0.8(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
+ '@vueuse/core':
+ specifier: catalog:frontend
+ version: 14.4.0(vue@3.5.40(typescript@5.9.3))
+ devframe:
+ specifier: workspace:*
+ version: link:../devframe
+ dompurify:
+ specifier: catalog:frontend
+ version: 3.4.13
+ fuse.js:
+ specifier: catalog:frontend
+ version: 7.5.0
+ iframe-pane:
+ specifier: catalog:frontend
+ version: 1.1.0
+ magic-string:
+ specifier: catalog:build
+ version: 0.30.21
+ storybook:
+ specifier: catalog:storybook
+ version: 10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ tinyglobby:
+ specifier: catalog:deps
+ version: 0.2.17
+ tsdown:
+ specifier: catalog:build
+ version: 0.22.14(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(tsx@4.23.6)(typescript@5.9.3)
+ tsx:
+ specifier: catalog:build
+ version: 4.23.6
+ unocss:
+ specifier: catalog:frontend
+ version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.23))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))
+ vite:
+ specifier: catalog:build
+ version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
+ vue:
+ specifier: catalog:frontend
+ version: 3.5.40(typescript@5.9.3)
+
packages/json-render:
dependencies:
'@json-render/core':
@@ -997,7 +1075,7 @@ importers:
version: 10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
tsdown:
specifier: catalog:build
- version: 0.22.14(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(tsx@4.23.6)(typescript@6.0.3)
+ version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(oxc-resolver@11.24.2)(tsx@4.23.6)(typescript@6.0.3)
unocss:
specifier: catalog:frontend
version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.23))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))
@@ -3673,9 +3751,6 @@ packages:
'@oxc-project/types@0.138.0':
resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==}
- '@oxc-project/types@0.139.0':
- resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
-
'@oxc-project/types@0.140.0':
resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==}
@@ -4059,73 +4134,36 @@ packages:
'@types/react':
optional: true
- '@rolldown/binding-android-arm64@1.1.5':
- resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [android]
-
'@rolldown/binding-android-arm64@1.2.0':
resolution: {integrity: sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@rolldown/binding-darwin-arm64@1.1.5':
- resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [darwin]
-
'@rolldown/binding-darwin-arm64@1.2.0':
resolution: {integrity: sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.1.5':
- resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [darwin]
-
'@rolldown/binding-darwin-x64@1.2.0':
resolution: {integrity: sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@rolldown/binding-freebsd-x64@1.1.5':
- resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [freebsd]
-
'@rolldown/binding-freebsd-x64@1.2.0':
resolution: {integrity: sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-linux-arm-gnueabihf@1.1.5':
- resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm]
- os: [linux]
-
'@rolldown/binding-linux-arm-gnueabihf@1.2.0':
resolution: {integrity: sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm64-gnu@1.1.5':
- resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
'@rolldown/binding-linux-arm64-gnu@1.2.0':
resolution: {integrity: sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4133,13 +4171,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-arm64-musl@1.1.5':
- resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
'@rolldown/binding-linux-arm64-musl@1.2.0':
resolution: {integrity: sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4147,13 +4178,6 @@ packages:
os: [linux]
libc: [musl]
- '@rolldown/binding-linux-ppc64-gnu@1.1.5':
- resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [ppc64]
- os: [linux]
- libc: [glibc]
-
'@rolldown/binding-linux-ppc64-gnu@1.2.0':
resolution: {integrity: sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4161,13 +4185,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-s390x-gnu@1.1.5':
- resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [s390x]
- os: [linux]
- libc: [glibc]
-
'@rolldown/binding-linux-s390x-gnu@1.2.0':
resolution: {integrity: sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4175,13 +4192,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-x64-gnu@1.1.5':
- resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
'@rolldown/binding-linux-x64-gnu@1.2.0':
resolution: {integrity: sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4189,13 +4199,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-x64-musl@1.1.5':
- resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
'@rolldown/binding-linux-x64-musl@1.2.0':
resolution: {integrity: sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4203,46 +4206,23 @@ packages:
os: [linux]
libc: [musl]
- '@rolldown/binding-openharmony-arm64@1.1.5':
- resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [openharmony]
-
'@rolldown/binding-openharmony-arm64@1.2.0':
resolution: {integrity: sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@rolldown/binding-wasm32-wasi@1.1.5':
- resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [wasm32]
-
'@rolldown/binding-wasm32-wasi@1.2.0':
resolution: {integrity: sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
- '@rolldown/binding-win32-arm64-msvc@1.1.5':
- resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [win32]
-
'@rolldown/binding-win32-arm64-msvc@1.2.0':
resolution: {integrity: sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.1.5':
- resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [win32]
-
'@rolldown/binding-win32-x64-msvc@1.2.0':
resolution: {integrity: sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5125,6 +5105,9 @@ packages:
'@unocss/preset-wind@66.7.5':
resolution: {integrity: sha512-LVKgGr0A9Lc7F85xGXrrb71DDXMlHGA8bcj/Kht8BCsuRdBZadNbLlnv5qnSJiHYhi3/blzcgVoaeRor6L9yNA==}
+ '@unocss/reset@66.7.5':
+ resolution: {integrity: sha512-z3wbt2cuQPjtT6w5xzRYZYFIIlUPzhVKz8yIcE9fvu7BgR3hO7uVsg/5mzDoGwQ96Ya3Sk68rpmI/gLYl4bJag==}
+
'@unocss/rule-utils@66.7.5':
resolution: {integrity: sha512-/AKHBRF6ZOexE3EDDv8ZEYh40P5e2Eha41IYUbE1wjigW7++ssmvimSTdufJzZvU5DGP++E3B3WEsFPprZMjAg==}
@@ -7348,73 +7331,36 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
- lightningcss-android-arm64@1.32.0:
- resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [android]
-
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
- lightningcss-darwin-arm64@1.32.0:
- resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [darwin]
-
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
- lightningcss-darwin-x64@1.32.0:
- resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [darwin]
-
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
- lightningcss-freebsd-x64@1.32.0:
- resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [freebsd]
-
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
- lightningcss-linux-arm-gnueabihf@1.32.0:
- resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm]
- os: [linux]
-
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
- lightningcss-linux-arm64-gnu@1.32.0:
- resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
@@ -7422,13 +7368,6 @@ packages:
os: [linux]
libc: [glibc]
- lightningcss-linux-arm64-musl@1.32.0:
- resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
@@ -7436,13 +7375,6 @@ packages:
os: [linux]
libc: [musl]
- lightningcss-linux-x64-gnu@1.32.0:
- resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
@@ -7450,13 +7382,6 @@ packages:
os: [linux]
libc: [glibc]
- lightningcss-linux-x64-musl@1.32.0:
- resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
@@ -7464,34 +7389,18 @@ packages:
os: [linux]
libc: [musl]
- lightningcss-win32-arm64-msvc@1.32.0:
- resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [win32]
-
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
- lightningcss-win32-x64-msvc@1.32.0:
- resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [win32]
-
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
- lightningcss@1.32.0:
- resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
- engines: {node: '>= 12.0.0'}
-
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
@@ -8619,11 +8528,6 @@ packages:
rolldown:
optional: true
- rolldown@1.1.5:
- resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
-
rolldown@1.2.0:
resolution: {integrity: sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -9572,49 +9476,6 @@ packages:
peerDependencies:
vite: 5.x || 6.x || 7.x || 8.x
- vite@8.1.5:
- resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
- peerDependencies:
- '@types/node': ^20.19.0 || >=22.12.0
- '@vitejs/devtools': ^0.3.0
- esbuild: ^0.27.0 || ^0.28.0
- jiti: '>=1.21.0'
- less: ^4.0.0
- sass: ^1.70.0
- sass-embedded: ^1.70.0
- stylus: '>=0.54.8'
- sugarss: ^5.0.0
- terser: ^5.16.0
- tsx: ^4.8.1
- yaml: ^2.4.2
- peerDependenciesMeta:
- '@types/node':
- optional: true
- '@vitejs/devtools':
- optional: true
- esbuild:
- optional: true
- jiti:
- optional: true
- less:
- optional: true
- sass:
- optional: true
- sass-embedded:
- optional: true
- stylus:
- optional: true
- sugarss:
- optional: true
- terser:
- optional: true
- tsx:
- optional: true
- yaml:
- optional: true
-
vite@8.2.0:
resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -10927,6 +10788,12 @@ snapshots:
transitivePeerDependencies:
- zod
+ '@json-render/vue@0.19.0(vue@3.5.40(typescript@5.9.3))(zod@4.4.3)':
+ dependencies:
+ '@json-render/core': 0.19.0(zod@4.4.3)
+ vue: 3.5.40(typescript@5.9.3)
+ zod: 4.4.3
+
'@json-render/vue@0.19.0(vue@3.5.40(typescript@6.0.3))(zod@4.4.3)':
dependencies:
'@json-render/core': 0.19.0(zod@4.4.3)
@@ -11379,8 +11246,8 @@ snapshots:
'@nuxt/vite-builder@4.5.1(60278ce03faae2f9a4e9716d50b5df79)':
dependencies:
'@nuxt/kit': 4.5.1(magic-string@1.0.0)(magicast@0.5.2)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)))
- '@vitejs/plugin-vue': 6.0.8(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))
- '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))
+ '@vitejs/plugin-vue': 6.0.8(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))
+ '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))
autoprefixer: 10.5.4(postcss@8.5.23)
consola: 3.4.2
cssnano: 8.0.2(postcss@8.5.23)
@@ -11404,9 +11271,9 @@ snapshots:
std-env: 4.2.0
ufo: 1.6.4
unenv: 2.0.0-rc.24
- vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
+ vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
vite-node: 6.0.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
- vite-plugin-checker: 0.14.5(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))
+ vite-plugin-checker: 0.14.5(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))
vue: 3.5.40(typescript@6.0.3)
vue-bundle-renderer: 2.3.1
optionalDependencies:
@@ -11767,8 +11634,6 @@ snapshots:
'@oxc-project/types@0.138.0': {}
- '@oxc-project/types@0.139.0': {}
-
'@oxc-project/types@0.140.0': {}
'@oxc-project/types@0.142.0': {}
@@ -12065,85 +11930,42 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
- '@rolldown/binding-android-arm64@1.1.5':
- optional: true
-
'@rolldown/binding-android-arm64@1.2.0':
optional: true
- '@rolldown/binding-darwin-arm64@1.1.5':
- optional: true
-
'@rolldown/binding-darwin-arm64@1.2.0':
optional: true
- '@rolldown/binding-darwin-x64@1.1.5':
- optional: true
-
'@rolldown/binding-darwin-x64@1.2.0':
optional: true
- '@rolldown/binding-freebsd-x64@1.1.5':
- optional: true
-
'@rolldown/binding-freebsd-x64@1.2.0':
optional: true
- '@rolldown/binding-linux-arm-gnueabihf@1.1.5':
- optional: true
-
'@rolldown/binding-linux-arm-gnueabihf@1.2.0':
optional: true
- '@rolldown/binding-linux-arm64-gnu@1.1.5':
- optional: true
-
'@rolldown/binding-linux-arm64-gnu@1.2.0':
optional: true
- '@rolldown/binding-linux-arm64-musl@1.1.5':
- optional: true
-
'@rolldown/binding-linux-arm64-musl@1.2.0':
optional: true
- '@rolldown/binding-linux-ppc64-gnu@1.1.5':
- optional: true
-
'@rolldown/binding-linux-ppc64-gnu@1.2.0':
optional: true
- '@rolldown/binding-linux-s390x-gnu@1.1.5':
- optional: true
-
'@rolldown/binding-linux-s390x-gnu@1.2.0':
optional: true
- '@rolldown/binding-linux-x64-gnu@1.1.5':
- optional: true
-
'@rolldown/binding-linux-x64-gnu@1.2.0':
optional: true
- '@rolldown/binding-linux-x64-musl@1.1.5':
- optional: true
-
'@rolldown/binding-linux-x64-musl@1.2.0':
optional: true
- '@rolldown/binding-openharmony-arm64@1.1.5':
- optional: true
-
'@rolldown/binding-openharmony-arm64@1.2.0':
optional: true
- '@rolldown/binding-wasm32-wasi@1.1.5':
- dependencies:
- '@emnapi/core': 1.11.1
- '@emnapi/runtime': 1.11.1
- '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
- optional: true
-
'@rolldown/binding-wasm32-wasi@1.2.0':
dependencies:
'@emnapi/core': 1.11.2
@@ -12151,15 +11973,9 @@ snapshots:
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
optional: true
- '@rolldown/binding-win32-arm64-msvc@1.1.5':
- optional: true
-
'@rolldown/binding-win32-arm64-msvc@1.2.0':
optional: true
- '@rolldown/binding-win32-x64-msvc@1.1.5':
- optional: true
-
'@rolldown/binding-win32-x64-msvc@1.2.0':
optional: true
@@ -12559,6 +12375,22 @@ snapshots:
ts-dedent: 2.2.0
type-fest: 5.6.0
+ '@storybook/vue3-vite@10.5.6(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))':
+ dependencies:
+ '@storybook/builder-vite': 10.5.6(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))
+ '@storybook/vue3': 10.5.6(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vue@3.5.40(typescript@5.9.3))
+ magic-string: 0.30.21
+ storybook: 10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ typescript: 5.9.3
+ vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
+ vue-component-meta: 3.3.7(typescript@5.9.3)
+ vue-docgen-api: 4.79.2(vue@3.5.40(typescript@5.9.3))
+ transitivePeerDependencies:
+ - esbuild
+ - rollup
+ - vue
+ - webpack
+
'@storybook/vue3-vite@10.5.6(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))':
dependencies:
'@storybook/builder-vite': 10.5.6(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))
@@ -12575,6 +12407,14 @@ snapshots:
- vue
- webpack
+ '@storybook/vue3@10.5.6(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vue@3.5.40(typescript@5.9.3))':
+ dependencies:
+ '@storybook/global': 5.0.0
+ storybook: 10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ type-fest: 5.6.0
+ vue: 3.5.40(typescript@5.9.3)
+ vue-component-type-helpers: 3.3.5
+
'@storybook/vue3@10.5.6(storybook@10.5.6(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vue@3.5.40(typescript@6.0.3))':
dependencies:
'@storybook/global': 5.0.0
@@ -13127,6 +12967,8 @@ snapshots:
'@unocss/core': 66.7.5
'@unocss/preset-wind3': 66.7.5
+ '@unocss/reset@66.7.5': {}
+
'@unocss/rule-utils@66.7.5':
dependencies:
'@unocss/core': 66.7.5
@@ -13209,23 +13051,23 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.47
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
- '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))':
+ '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))':
dependencies:
'@babel/core': 7.29.0(supports-color@10.2.2)
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.0(supports-color@10.2.2))
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2)
'@rolldown/pluginutils': 1.0.1
'@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2)
- vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
+ vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
vue: 3.5.40(typescript@6.0.3)
transitivePeerDependencies:
- supports-color
- '@vitejs/plugin-vue@6.0.8(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))':
+ '@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
- vue: 3.5.40(typescript@6.0.3)
+ vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
+ vue: 3.5.40(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))':
dependencies:
@@ -13472,6 +13314,13 @@ snapshots:
'@vueuse/shared': 14.3.0(vue@3.5.40(typescript@6.0.3))
vue: 3.5.40(typescript@6.0.3)
+ '@vueuse/core@14.4.0(vue@3.5.40(typescript@5.9.3))':
+ dependencies:
+ '@types/web-bluetooth': 0.0.21
+ '@vueuse/metadata': 14.4.0
+ '@vueuse/shared': 14.4.0(vue@3.5.40(typescript@5.9.3))
+ vue: 3.5.40(typescript@5.9.3)
+
'@vueuse/core@14.4.0(vue@3.5.40(typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
@@ -13497,6 +13346,10 @@ snapshots:
dependencies:
vue: 3.5.40(typescript@6.0.3)
+ '@vueuse/shared@14.4.0(vue@3.5.40(typescript@5.9.3))':
+ dependencies:
+ vue: 3.5.40(typescript@5.9.3)
+
'@vueuse/shared@14.4.0(vue@3.5.40(typescript@6.0.3))':
dependencies:
vue: 3.5.40(typescript@6.0.3)
@@ -15373,88 +15226,39 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
- lightningcss-android-arm64@1.32.0:
- optional: true
-
lightningcss-android-arm64@1.33.0:
optional: true
- lightningcss-darwin-arm64@1.32.0:
- optional: true
-
lightningcss-darwin-arm64@1.33.0:
optional: true
- lightningcss-darwin-x64@1.32.0:
- optional: true
-
lightningcss-darwin-x64@1.33.0:
optional: true
- lightningcss-freebsd-x64@1.32.0:
- optional: true
-
lightningcss-freebsd-x64@1.33.0:
optional: true
- lightningcss-linux-arm-gnueabihf@1.32.0:
- optional: true
-
lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
- lightningcss-linux-arm64-gnu@1.32.0:
- optional: true
-
lightningcss-linux-arm64-gnu@1.33.0:
optional: true
- lightningcss-linux-arm64-musl@1.32.0:
- optional: true
-
lightningcss-linux-arm64-musl@1.33.0:
optional: true
- lightningcss-linux-x64-gnu@1.32.0:
- optional: true
-
lightningcss-linux-x64-gnu@1.33.0:
optional: true
- lightningcss-linux-x64-musl@1.32.0:
- optional: true
-
lightningcss-linux-x64-musl@1.33.0:
optional: true
- lightningcss-win32-arm64-msvc@1.32.0:
- optional: true
-
lightningcss-win32-arm64-msvc@1.33.0:
optional: true
- lightningcss-win32-x64-msvc@1.32.0:
- optional: true
-
lightningcss-win32-x64-msvc@1.33.0:
optional: true
- lightningcss@1.32.0:
- dependencies:
- detect-libc: 2.1.2
- optionalDependencies:
- lightningcss-android-arm64: 1.32.0
- lightningcss-darwin-arm64: 1.32.0
- lightningcss-darwin-x64: 1.32.0
- lightningcss-freebsd-x64: 1.32.0
- lightningcss-linux-arm-gnueabihf: 1.32.0
- lightningcss-linux-arm64-gnu: 1.32.0
- lightningcss-linux-arm64-musl: 1.32.0
- lightningcss-linux-x64-gnu: 1.32.0
- lightningcss-linux-x64-musl: 1.32.0
- lightningcss-win32-arm64-msvc: 1.32.0
- lightningcss-win32-x64-msvc: 1.32.0
-
lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
@@ -17160,7 +16964,7 @@ snapshots:
robust-predicates@3.0.3: {}
- rolldown-plugin-dts@0.27.13(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(rolldown@1.2.0)(typescript@6.0.3):
+ rolldown-plugin-dts@0.27.13(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(rolldown@1.2.0)(typescript@5.9.3):
dependencies:
dts-resolver: 3.0.0(oxc-resolver@11.24.2)
get-tsconfig: 5.0.0-beta.5
@@ -17171,7 +16975,7 @@ snapshots:
yuku-parser: 0.7.4
optionalDependencies:
'@volar/typescript': 2.4.28(typescript@5.9.3)
- typescript: 6.0.3
+ typescript: 5.9.3
transitivePeerDependencies:
- oxc-resolver
@@ -17196,27 +17000,6 @@ snapshots:
optionalDependencies:
rolldown: 1.2.0
- rolldown@1.1.5:
- dependencies:
- '@oxc-project/types': 0.139.0
- '@rolldown/pluginutils': 1.0.1
- optionalDependencies:
- '@rolldown/binding-android-arm64': 1.1.5
- '@rolldown/binding-darwin-arm64': 1.1.5
- '@rolldown/binding-darwin-x64': 1.1.5
- '@rolldown/binding-freebsd-x64': 1.1.5
- '@rolldown/binding-linux-arm-gnueabihf': 1.1.5
- '@rolldown/binding-linux-arm64-gnu': 1.1.5
- '@rolldown/binding-linux-arm64-musl': 1.1.5
- '@rolldown/binding-linux-ppc64-gnu': 1.1.5
- '@rolldown/binding-linux-s390x-gnu': 1.1.5
- '@rolldown/binding-linux-x64-gnu': 1.1.5
- '@rolldown/binding-linux-x64-musl': 1.1.5
- '@rolldown/binding-openharmony-arm64': 1.1.5
- '@rolldown/binding-wasm32-wasi': 1.1.5
- '@rolldown/binding-win32-arm64-msvc': 1.1.5
- '@rolldown/binding-win32-x64-msvc': 1.1.5
-
rolldown@1.2.0:
dependencies:
'@oxc-project/types': 0.140.0
@@ -17596,7 +17379,7 @@ snapshots:
recast: 0.23.11
semver: 7.8.5
use-sync-external-store: 1.6.0(react@19.2.8)
- ws: 8.21.1
+ ws: 8.21.2
optionalDependencies:
'@types/react': 19.2.18
transitivePeerDependencies:
@@ -17851,7 +17634,7 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
- tsdown@0.22.14(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(tsx@4.23.6)(typescript@6.0.3):
+ tsdown@0.22.14(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(tsx@4.23.6)(typescript@5.9.3):
dependencies:
ansis: 4.3.1
cac: 7.0.0
@@ -17862,7 +17645,7 @@ snapshots:
obug: 2.1.4
picomatch: 4.0.5
rolldown: 1.2.0
- rolldown-plugin-dts: 0.27.13(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(rolldown@1.2.0)(typescript@6.0.3)
+ rolldown-plugin-dts: 0.27.13(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(rolldown@1.2.0)(typescript@5.9.3)
tinyexec: 1.2.4
tinyglobby: 0.2.17
tree-kill: 1.2.2
@@ -17870,7 +17653,7 @@ snapshots:
verkit: 0.3.0
optionalDependencies:
tsx: 4.23.6
- typescript: 6.0.3
+ typescript: 5.9.3
transitivePeerDependencies:
- '@typescript/native-preview'
- '@volar/typescript'
@@ -18220,7 +18003,7 @@ snapshots:
es-module-lexer: 2.1.0
obug: 2.1.4
pathe: 2.0.3
- vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
+ vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- '@vitejs/devtools'
@@ -18235,7 +18018,7 @@ snapshots:
- tsx
- yaml
- vite-plugin-checker@0.14.5(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)):
+ vite-plugin-checker@0.14.5(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)):
dependencies:
'@babel/code-frame': 7.29.7
chokidar: 5.0.0
@@ -18244,7 +18027,7 @@ snapshots:
picomatch: 4.0.5
proper-lockfile: 4.1.2
tiny-invariant: 1.3.3
- vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
+ vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
optionalDependencies:
eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2)
optionator: 0.9.4
@@ -18306,22 +18089,6 @@ snapshots:
stack-trace: 1.0.0
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0)
- vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0):
- dependencies:
- lightningcss: 1.32.0
- picomatch: 4.0.5
- postcss: 8.5.23
- rolldown: 1.1.5
- tinyglobby: 0.2.17
- optionalDependencies:
- '@types/node': 26.1.2
- esbuild: 0.28.0
- fsevents: 2.3.3
- jiti: 2.7.0
- terser: 5.47.1
- tsx: 4.23.6
- yaml: 2.9.0
-
vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.6)(yaml@2.9.0):
dependencies:
lightningcss: 1.33.0
@@ -18449,6 +18216,22 @@ snapshots:
vue-devtools-stub@0.1.0: {}
+ vue-docgen-api@4.79.2(vue@3.5.40(typescript@5.9.3)):
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@vue/compiler-dom': 3.5.40
+ '@vue/compiler-sfc': 3.5.40
+ ast-types: 0.16.1
+ esm-resolve: 1.0.11
+ hash-sum: 2.0.0
+ lru-cache: 8.0.5
+ pug: 3.0.4
+ recast: 0.23.11
+ ts-map: 1.0.3
+ vue: 3.5.40(typescript@5.9.3)
+ vue-inbrowser-compiler-independent-utils: 4.71.1(vue@3.5.40(typescript@5.9.3))
+
vue-docgen-api@4.79.2(vue@3.5.40(typescript@6.0.3)):
dependencies:
'@babel/parser': 7.29.7
@@ -18477,6 +18260,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ vue-inbrowser-compiler-independent-utils@4.71.1(vue@3.5.40(typescript@5.9.3)):
+ dependencies:
+ vue: 3.5.40(typescript@5.9.3)
+
vue-inbrowser-compiler-independent-utils@4.71.1(vue@3.5.40(typescript@6.0.3)):
dependencies:
vue: 3.5.40(typescript@6.0.3)
@@ -18519,6 +18306,16 @@ snapshots:
- unloader
- webpack
+ vue@3.5.40(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.40
+ '@vue/compiler-sfc': 3.5.40
+ '@vue/runtime-dom': 3.5.40
+ '@vue/server-renderer': 3.5.40
+ '@vue/shared': 3.5.40
+ optionalDependencies:
+ typescript: 5.9.3
+
vue@3.5.40(typescript@6.0.3):
dependencies:
'@vue/compiler-dom': 3.5.40
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 910b93e0..2ebb215a 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -62,6 +62,7 @@ catalogs:
'@nuxt/kit': *nuxt
'@preact/preset-vite': ^2.10.6
'@vitejs/plugin-vue': ^6.0.8
+ magic-string: ^0.30.21
mlly: ^1.8.2
nuxt: ^4.5.1
tsdown: ^0.22.14
@@ -122,6 +123,7 @@ catalogs:
'@sveltejs/vite-plugin-svelte': ^7.2.0
'@unocss/postcss': *unocss
'@unocss/preset-icons': *unocss
+ '@unocss/reset': *unocss
'@vueuse/core': *vueuse
'@xterm/addon-fit': ^0.11.0
'@xterm/xterm': ^6.0.0
@@ -130,6 +132,7 @@ catalogs:
colorjs.io: ^0.7.1
dompurify: ^3.4.13
floating-vue: ^5.2.2
+ fuse.js: ^7.1.0
iframe-pane: ^1.1.0
next: ^16.3.0
preact: ^10.29.8
diff --git a/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts
new file mode 100644
index 00000000..7b124bb8
--- /dev/null
+++ b/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by tsnapi — public API snapshot of `@devframes/hub-ui`
+ */
+// #region Interfaces
+export interface CreateUiOptions {
+ viewer?: boolean;
+ embedded?: boolean;
+}
+// #endregion
+
+// #region Functions
+export declare function createUi(_?: CreateUiOptions): DevframeHubUi;
+// #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.js
new file mode 100644
index 00000000..c4f83aea
--- /dev/null
+++ b/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.js
@@ -0,0 +1,6 @@
+/**
+ * Generated by tsnapi — public API snapshot of `@devframes/hub-ui`
+ */
+// #region Functions
+export function createUi(_) {}
+// #endregion
\ No newline at end of file
diff --git a/tsconfig.base.json b/tsconfig.base.json
index f311e3e7..9944e599 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -133,6 +133,9 @@
"@devframes/hub": [
"./packages/hub/src/index.ts"
],
+ "@devframes/hub-ui": [
+ "./packages/hub-ui/src/index.ts"
+ ],
"@devframes/nuxt/runtime/plugin.client": [
"./packages/nuxt/src/runtime/plugin.client.ts"
],
diff --git a/turbo.json b/turbo.json
index 963a5a8a..9b876dab 100644
--- a/turbo.json
+++ b/turbo.json
@@ -27,6 +27,11 @@
"dependsOn": ["devframe#build", "@devframes/hub#build"],
"outputs": ["dist/**"]
},
+ "@devframes/hub-ui#build": {
+ "outputLogs": "new-only",
+ "dependsOn": ["devframe#build", "@devframes/hub#build"],
+ "outputs": ["dist/**"]
+ },
"@devframes/json-render-ui#build": {
"outputLogs": "new-only",
"dependsOn": ["@devframes/json-render#build"],
]