diff --git a/design/dock-icon.ts b/design/dock-icon.ts
new file mode 100644
index 00000000..7a9836af
--- /dev/null
+++ b/design/dock-icon.ts
@@ -0,0 +1,45 @@
+// Framework-neutral port of @antfu/design's `DisplayIconifyRemoteIcon`:
+// https://github.com/antfu/design/blob/main/packages/design/components/Display/DisplayIconifyRemoteIcon.vue
+//
+// Resolves a devframe dock `icon` (an Iconify `collection:icon` id, e.g.
+// `ph:git-branch-duotone`) to its live, sanitized SVG markup, fetched from the
+// public `api.iconify.design` CDN. Unlike a UnoCSS `preset-icons` class, this
+// needs no `@iconify-json/*` collection installed and no hand-maintained
+// id -> class table — any Iconify id just works, at the cost of a network
+// round-trip on first render. We reuse @antfu/design's own fetcher, cache and
+// sanitizer (`utils/iconify.ts`) rather than reimplementing them; only the id
+// parsing and light/dark selection below are devframe-specific, mirroring the
+// upstream Vue component's own `icon` prop parsing. Vue surfaces should render
+// `DisplayIconifyRemoteIcon` directly instead of using this port.
+import { getIconifySvg } from '@antfu/design/utils/iconify'
+
+// Mirrors DisplayIconifyRemoteIcon.vue's own `collection:icon` parse (with an
+// optional `i-` prefix tolerated so a UnoCSS-style id also works).
+const ICONIFY_ID = /^(?:i-)?([\w-]+):([\w-]+)$/
+
+/**
+ * Resolve a dock icon (a `collection:icon` string, or a `{ light, dark }`
+ * pair — the `light` variant is fetched) to its sanitized SVG markup.
+ *
+ * Returns `undefined` when the id doesn't parse or the fetch fails, so the
+ * caller can fall back to a text initial.
+ *
+ * @example
+ * await dockIconSvg('ph:git-branch-duotone') // → ''
+ */
+export async function dockIconSvg(name: string | { light: string, dark: string } | undefined): Promise {
+ const id = typeof name === 'string' ? name : name?.light
+ if (!id)
+ return undefined
+ const match = id.match(ICONIFY_ID)
+ if (!match)
+ return undefined
+ try {
+ return await getIconifySvg(match[1]!, match[2]!)
+ }
+ catch {
+ // A failed fetch (offline / flaky CDN) degrades to the text-initial
+ // fallback, not a thrown error out of a render path.
+ return undefined
+ }
+}
diff --git a/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json
index 7edf0dfd..2fb5b770 100644
--- a/examples/minimal-next-devframe-hub/package.json
+++ b/examples/minimal-next-devframe-hub/package.json
@@ -23,6 +23,7 @@
"@json-render/react": "catalog:frontend",
"colorjs.io": "catalog:frontend",
"devframe": "workspace:*",
+ "dompurify": "catalog:frontend",
"minimal-json-render": "workspace:*",
"next": "catalog:frontend",
"react": "catalog:frontend",
diff --git a/examples/minimal-next-devframe-hub/src/client/app/icons.ts b/examples/minimal-next-devframe-hub/src/client/app/icons.ts
index be6d4885..cc572f39 100644
--- a/examples/minimal-next-devframe-hub/src/client/app/icons.ts
+++ b/examples/minimal-next-devframe-hub/src/client/app/icons.ts
@@ -1,35 +1,5 @@
-// @unocss-include
-// Map a devframe dock `icon` (e.g. `ph:git-branch-duotone`) to a UnoCSS
-// `preset-icons` class. Keeping the class strings literal lets UnoCSS
-// statically extract them and inline only these glyphs from `@iconify-json/ph`
-// at build time — the same icon strategy vite-devtools uses. Add a row here to
-// support another dock icon.
-const ICON_CLASS: Record = {
- 'ph:git-branch-duotone': 'i-ph-git-branch-duotone',
- 'ph:terminal-window-duotone': 'i-ph-terminal-window-duotone',
- 'ph:code-duotone': 'i-ph-code-duotone',
- 'ph:stethoscope-duotone': 'i-ph-stethoscope-duotone',
- 'ph:wheelchair-duotone': 'i-ph-wheelchair-duotone',
- 'ph:rocket-duotone': 'i-ph-rocket-duotone',
- 'ph:wrench-duotone': 'i-ph-wrench-duotone',
- 'ph:plug-duotone': 'i-ph-plug-duotone',
- 'ph:layout-duotone': 'i-ph-layout-duotone',
- 'ph:note-pencil-duotone': 'i-ph-note-pencil-duotone',
- 'ph:squares-four-duotone': 'i-ph-squares-four-duotone',
- 'ph:house-duotone': 'i-ph-house-duotone',
- 'ph:puzzle-piece-duotone': 'i-ph-puzzle-piece-duotone',
- 'ph:clock-duotone': 'i-ph-clock-duotone',
- 'ph:gear-duotone': 'i-ph-gear-duotone',
- 'ph:sliders-horizontal-duotone': 'i-ph-sliders-horizontal-duotone',
-}
-
-/**
- * Resolve a dock icon to its UnoCSS class, or an empty string when the icon
- * isn't mapped (the caller falls back to a text initial).
- */
-export function iconClass(name: string | { light: string, dark: string } | undefined): string {
- if (!name)
- return ''
- const id = typeof name === 'string' ? name : name.light
- return ICON_CLASS[id] ?? ''
-}
+// Re-exports the shared devframe dock-icon resolver (see
+// `design/dock-icon.ts`, a port of @antfu/design's `DisplayIconifyRemoteIcon`)
+// so this surface stays in lockstep with every other hub shell instead of
+// hand-maintaining its own icon table.
+export { dockIconSvg } from '../../../../../design/dock-icon'
diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx
index 722c0e08..f7949c42 100644
--- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx
+++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx
@@ -13,7 +13,7 @@ import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
import { connectDevframe, createDevframeClientHost, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer'
-import { iconClass } from './icons'
+import { dockIconSvg } from './icons'
const HUB_BASE = '/__hub/'
@@ -138,11 +138,32 @@ function createClientPlaygroundSpec(clientType: string): DevframeJsonRenderSpec
}
}
-/** Render a dock icon, falling back to the title's initial when unmapped. */
+/** Fetches (and caches, for the component's lifetime) a dock icon's sanitized SVG. */
+function useDockIconSvg(icon: DevframeDockEntry['icon']): string | undefined {
+ const [svg, setSvg] = useState(undefined)
+ const key = typeof icon === 'string' ? icon : icon?.light
+
+ useEffect(() => {
+ let cancelled = false
+ setSvg(undefined)
+ void dockIconSvg(icon).then((resolved) => {
+ if (!cancelled)
+ setSvg(resolved)
+ })
+ return () => {
+ cancelled = true
+ }
+ // Re-fetch only when the icon id itself changes, not on every `icon` object identity.
+ }, [key])
+
+ return svg
+}
+
+/** Render a dock icon, falling back to the title's initial while it loads or when unmapped. */
function DockIcon({ entry }: { entry: DevframeDockEntry }) {
- const cls = iconClass(entry.icon)
- if (cls)
- return
+ const svg = useDockIconSvg(entry.icon)
+ if (svg)
+ return
const initial = (entry.title?.[0] ?? '?').toUpperCase()
return {initial}
}
diff --git a/examples/minimal-next-devframe-hub/src/client/next.config.mjs b/examples/minimal-next-devframe-hub/src/client/next.config.mjs
index af0d54b8..766add98 100644
--- a/examples/minimal-next-devframe-hub/src/client/next.config.mjs
+++ b/examples/minimal-next-devframe-hub/src/client/next.config.mjs
@@ -1,6 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: { unoptimized: true },
+ // @antfu/design ships raw, uncompiled `.ts`/`.vue` source (see its README —
+ // "no bundling, your build compiles it"). `dockIconSvg` (design/dock-icon.ts)
+ // imports its `utils/iconify.ts` directly, so Next/Turbopack — which
+ // otherwise treats node_modules as pre-built and has no loader for a bare
+ // `.ts` file there — needs to run this package through its own transform.
+ transpilePackages: ['@antfu/design'],
// The workspace typecheck owns source-level project references.
typescript: { ignoreBuildErrors: true },
// Mounted devframe SPAs are served at `/__/` and reference their assets
diff --git a/examples/minimal-vite-devframe-hub/package.json b/examples/minimal-vite-devframe-hub/package.json
index ceef72c7..8e0af049 100644
--- a/examples/minimal-vite-devframe-hub/package.json
+++ b/examples/minimal-vite-devframe-hub/package.json
@@ -25,6 +25,7 @@
"@devframes/plugin-terminals": "workspace:*",
"colorjs.io": "catalog:frontend",
"devframe": "workspace:*",
+ "dompurify": "catalog:frontend",
"minimal-json-render": "workspace:*",
"vue": "catalog:frontend"
},
diff --git a/examples/minimal-vite-devframe-hub/src/client/icons.ts b/examples/minimal-vite-devframe-hub/src/client/icons.ts
index be6d4885..606d0e34 100644
--- a/examples/minimal-vite-devframe-hub/src/client/icons.ts
+++ b/examples/minimal-vite-devframe-hub/src/client/icons.ts
@@ -1,35 +1,5 @@
-// @unocss-include
-// Map a devframe dock `icon` (e.g. `ph:git-branch-duotone`) to a UnoCSS
-// `preset-icons` class. Keeping the class strings literal lets UnoCSS
-// statically extract them and inline only these glyphs from `@iconify-json/ph`
-// at build time — the same icon strategy vite-devtools uses. Add a row here to
-// support another dock icon.
-const ICON_CLASS: Record = {
- 'ph:git-branch-duotone': 'i-ph-git-branch-duotone',
- 'ph:terminal-window-duotone': 'i-ph-terminal-window-duotone',
- 'ph:code-duotone': 'i-ph-code-duotone',
- 'ph:stethoscope-duotone': 'i-ph-stethoscope-duotone',
- 'ph:wheelchair-duotone': 'i-ph-wheelchair-duotone',
- 'ph:rocket-duotone': 'i-ph-rocket-duotone',
- 'ph:wrench-duotone': 'i-ph-wrench-duotone',
- 'ph:plug-duotone': 'i-ph-plug-duotone',
- 'ph:layout-duotone': 'i-ph-layout-duotone',
- 'ph:note-pencil-duotone': 'i-ph-note-pencil-duotone',
- 'ph:squares-four-duotone': 'i-ph-squares-four-duotone',
- 'ph:house-duotone': 'i-ph-house-duotone',
- 'ph:puzzle-piece-duotone': 'i-ph-puzzle-piece-duotone',
- 'ph:clock-duotone': 'i-ph-clock-duotone',
- 'ph:gear-duotone': 'i-ph-gear-duotone',
- 'ph:sliders-horizontal-duotone': 'i-ph-sliders-horizontal-duotone',
-}
-
-/**
- * Resolve a dock icon to its UnoCSS class, or an empty string when the icon
- * isn't mapped (the caller falls back to a text initial).
- */
-export function iconClass(name: string | { light: string, dark: string } | undefined): string {
- if (!name)
- return ''
- const id = typeof name === 'string' ? name : name.light
- return ICON_CLASS[id] ?? ''
-}
+// Re-exports the shared devframe dock-icon resolver (see
+// `design/dock-icon.ts`, a port of @antfu/design's `DisplayIconifyRemoteIcon`)
+// so this surface stays in lockstep with every other hub shell instead of
+// hand-maintaining its own icon table.
+export { dockIconSvg } from '../../../../design/dock-icon'
diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts
index 994df9c7..75efd012 100644
--- a/examples/minimal-vite-devframe-hub/src/client/main.ts
+++ b/examples/minimal-vite-devframe-hub/src/client/main.ts
@@ -9,7 +9,7 @@ import type { DevframeJsonRenderSpec } from '@devframes/json-render'
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
import { connectDevframe, createDevframeClientHost, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
import { createJsonRenderDockRenderer } from '@devframes/json-render-ui'
-import { iconClass } from './icons'
+import { dockIconSvg } from './icons'
import 'virtual:uno.css'
import '@antfu/design/styles.css'
@@ -40,13 +40,48 @@ function renderList(host: HTMLElement, items: readonly T[], render: (item: T)
host.innerHTML = items.map(render).join('')
}
-/** Render a dock icon, falling back to the title's initial when unknown. */
+// Session-lifetime cache of resolved dock-icon SVGs, keyed by the icon id
+// (e.g. `ph:git-branch-duotone`) — `dockIconSvg` itself caches per fetch, this
+// just lets a re-render (e.g. a badge update) paint an already-resolved icon
+// synchronously instead of flashing the fallback initial again.
+const dockIconCache = new Map()
+
+function dockIconKey(icon: DevframeDockEntry['icon']): string | undefined {
+ return typeof icon === 'string' ? icon : icon?.light
+}
+
+/** Render a dock icon's placeholder markup: a resolved SVG if cached, else the title's initial. */
function dockIcon(entry: DevframeDockEntry): string {
- const cls = iconClass(entry.icon)
- if (cls)
- return ``
+ const key = entry.icon ? dockIconKey(entry.icon) : undefined
+ const cached = key ? dockIconCache.get(key) : undefined
+ if (cached)
+ return `${cached}`
const initial = (entry.title?.[0] ?? '?').toUpperCase()
- return `${initial}`
+ return `${initial}`
+}
+
+/**
+ * Resolve (and cache) each unresolved dock's icon SVG, then patch just that
+ * dock's `[data-dock-icon]` element in place — no full re-render, since the
+ * fetch is async and the dock list may already be showing by the time it
+ * settles. A dock with no icon or an unparsable/failed fetch keeps its
+ * fallback initial.
+ */
+async function hydrateDockIcons(list: readonly DevframeDockEntry[]): Promise {
+ await Promise.all(list.map(async (entry) => {
+ const key = entry.icon ? dockIconKey(entry.icon) : undefined
+ if (!key || dockIconCache.has(key))
+ return
+ const svg = await dockIconSvg(entry.icon) ?? null
+ dockIconCache.set(key, svg)
+ if (!svg)
+ return
+ const el = docksEl.querySelector(`[data-dock-icon="${entry.id}"]`)
+ if (el) {
+ el.className = 'h-5 w-5 shrink-0 text-lg'
+ el.innerHTML = svg
+ }
+ }))
}
function isIframeDock(d: DevframeDockEntry): d is DevframeDockEntry & { type: 'iframe', url: string } {
@@ -315,6 +350,7 @@ async function main() {
renderList(docksEl, list, d =>
``)
+ void hydrateDockIcons(list)
void showSelection(list)
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a131c1fe..9a72ad63 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -562,6 +562,9 @@ importers:
devframe:
specifier: workspace:*
version: link:../../packages/devframe
+ dompurify:
+ specifier: catalog:frontend
+ version: 3.4.12
minimal-json-render:
specifier: workspace:*
version: link:../minimal-json-render
@@ -647,6 +650,9 @@ importers:
devframe:
specifier: workspace:*
version: link:../../packages/devframe
+ dompurify:
+ specifier: catalog:frontend
+ version: 3.4.12
minimal-json-render:
specifier: workspace:*
version: link:../minimal-json-render