Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/content/8.references/4.node-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The fields of a `DevframeDefinition`: [Devframe Definition](/guide/devframe-defi
| `importMetaUrl` | `string` | **Recommended.** Pass `import.meta.url`, the deps resolution base: default `resolveFrom` for [remote assets](/guide/client-assets) and declared [services](/guide/services#wire-services). |
| `homepage` | `string` | **Required.** Homepage/docs URL. |
| `description` | `string` | **Required.** One-line summary. |
| `icon` | `string \| { light, dark }` | Optional Iconify name or URL; light/dark pairs. |
| `icon` | `string \| { light, dark }` | Optional Iconify name, image URL, or `mask:<image-url>`; light/dark pairs. |
| `basePath` | `string` | Optional mount-path override. Default `/` standalone (`cli`/`build`), `/__<id>/` hosted (`vite`/`embedded`). |
| `duplicationStrategy` | `'warn' \| 'silent' \| 'throw' \| 'duplicate'` | Hub reaction when another devframe shares this `id`. Default `'warn'`. See [Duplication strategies](/references/hub-api#duplication-strategies); standalone adapters ignore it. |
| `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. `boolean` = whole runtime; object = individual features. |
Expand All @@ -30,6 +30,8 @@ The fields of a `DevframeDefinition`: [Devframe Definition](/guide/devframe-defi
| `setup` | `(ctx, info?) => void \| Promise<void>` | **Required.** Server-side entry point, run in every runtime. Optional 2nd arg carries runtime metadata, notably parsed CLI `flags` under `createCac`. |
| `cli` | `DevframeCliOptions` | CLI adapter defaults. See [CLI options](#cli-options). |

The reference hub UI and terminal SPA render `mask:` icons with the surrounding text color, preserving the image's shape and opacity. For a bundled SVG, use `` `mask:data:image/svg+xml,${encodeURIComponent(svg)}` ``. A mask produces one color; use an ordinary image URL for multicolor artwork. Relative mask URLs on dock entries resolve against the supplying hub's URL. Custom hub UI providers implement this string convention in their own icon renderer.

## CLI options

The `cli` field's `DevframeCliOptions`: [CLI options](/guide/devframe-definition#cli-options).
Expand Down
21 changes: 19 additions & 2 deletions packages/hub-ui/src/client/components/icons/IconifyIcon.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import { getIconifySvg } from '../../utils/iconify'

const props = defineProps<{
icon: string
}>()

const isUrlIcon = computed(() => props.icon.includes('/') || props.icon.startsWith('data:') || props.icon.startsWith('builtin:'))
const maskUrl = computed(() => props.icon.startsWith('mask:') ? props.icon.slice(5).trim() : undefined)
const maskStyle = computed<CSSProperties | undefined>(() => {
if (!maskUrl.value)
return undefined
return {
backgroundColor: 'currentColor',
mask: `url(${JSON.stringify(maskUrl.value)}) center / contain no-repeat`,
maskMode: 'alpha',
}
})
const isUrlIcon = computed(() => maskUrl.value !== undefined || props.icon.includes('/') || props.icon.startsWith('data:') || props.icon.startsWith('builtin:'))
const iconifyParsed = computed(() => {
if (isUrlIcon.value)
return undefined
Expand Down Expand Up @@ -38,7 +49,13 @@ watchEffect(async () => {

<template>
<div
v-if="iconifyParsed"
v-if="maskUrl !== undefined"
aria-hidden="true"
class="w-full h-full"
:style="maskStyle"
/>
Comment thread
dvcolomban marked this conversation as resolved.
<div
v-else-if="iconifyParsed"
v-html="iconifyLoaded"
/>
<img
Expand Down
18 changes: 18 additions & 0 deletions packages/hub/src/client/dock-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,24 @@ describe('dock resource resolution', () => {
expect(resolveDockIcon('data:image/svg+xml;base64,abc', connection)).toBe('data:image/svg+xml;base64,abc')
})

it('resolves mask URLs and preserves mask data through JSON transport', () => {
expect.assertions(4)
const data = 'mask:data:image/svg+xml,%3Csvg%2F%3E'
const icon = JSON.parse(JSON.stringify({ light: data, dark: 'mask:./dark.svg' }))
expect(resolveDockIcon('mask:/icons/local.svg', connection)).toBe('mask:http://localhost:5173/icons/local.svg')
expect(resolveDockIcon('mask:icon', connection)).toBe('mask:http://localhost:5173/__devtools/icon')
expect(resolveDockIcon('mask:https://example.com/icon.svg', connection)).toBe('mask:https://example.com/icon.svg')
expect(resolveDockIcon(icon, connection)).toEqual({ light: data, dark: 'mask:http://localhost:5173/__devtools/dark.svg' })
})

it('trims mask URLs and preserves empty masks without resolving the metadata URL', () => {
expect.assertions(4)
expect(resolveDockIcon('mask: ./icon.svg ', connection)).toBe('mask:http://localhost:5173/__devtools/icon.svg')
expect(resolveDockIcon('mask: data:image/svg+xml,%3Csvg%2F%3E ', connection)).toBe('mask:data:image/svg+xml,%3Csvg%2F%3E')
expect(resolveDockIcon('mask:', connection)).toBe('mask:')
expect(resolveDockIcon('mask: ', connection)).toBe('mask:')
})

it('resolves light and dark icon variants independently', () => {
expect(resolveDockIcon({
light: './icons/light.svg',
Expand Down
11 changes: 11 additions & 0 deletions packages/hub/src/client/dock-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ function resolveResourceUrl(value: string, connection: DevframeConnection): stri

function resolveIconUrl(value: string, connection: DevframeConnection): string {
const url = value.trim()
if (url.startsWith('mask:')) {
const maskUrl = url.slice(5).trim()
if (!maskUrl)
return 'mask:'
try {
return `mask:${new URL(maskUrl, connection.metaBaseUrl).href}`
}
catch {
return url
}
}
if (!url || URL_SCHEME_RE.test(url) || url.startsWith('//'))
return url

Expand Down
21 changes: 18 additions & 3 deletions plugins/terminals/app/client/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,21 @@

<svelte:window onkeydown={onGlobalKey} />

{#snippet terminalIcon(icon: string, className = '')}
{#if icon.startsWith('mask:')}
{@const maskUrl = icon.slice(5).trim()}
<div
class="shrink-0 w-1em h-1em {className}"
aria-hidden="true"
style:background-color={maskUrl ? 'currentColor' : 'transparent'}
style:mask={maskUrl ? `url(${JSON.stringify(maskUrl)}) center / contain no-repeat` : 'none'}
style:mask-mode="alpha"
></div>
{:else}
<div class="{icon} shrink-0 {className}" aria-hidden="true"></div>
{/if}
{/snippet}

{#if connCopy}
<div class={connectionPanel('absolute inset-0 color-base font-sans')}>
<div class="{connCopy.icon} {connectionGlyph(connCopy.spin)}"></div>
Expand Down Expand Up @@ -399,7 +414,7 @@
>
<span class={dot(statusDot(s.status))}></span>
{#if s.icon}
<div class="{s.icon} shrink-0"></div>
{@render terminalIcon(s.icon)}
{/if}
<span class="truncate">{displayName(s)}</span>
<span
Expand Down Expand Up @@ -462,7 +477,7 @@
class="flex items-center gap-2 px2 py1.5 rounded text-sm text-left op-fade hover:(op100 bg-active) transition-colors"
onclick={() => runPreset(p.id)}
>
<div class="{p.icon || 'i-ph-terminal-duotone'} shrink-0 op-fade"></div>
{@render terminalIcon(p.icon || 'i-ph-terminal-duotone', 'op-fade')}
<span class="truncate flex-1">{p.title}</span>
<span class="font-mono text-xs op-mute">{p.mode === 'interactive' ? 'tty' : 'log'}</span>
</button>
Expand All @@ -482,7 +497,7 @@
</span>
<span class="font-mono truncate op-fade flex items-center gap-1.5" title={`${s.command} ${s.args.join(' ')}`}>
{#if s.icon}
<div class="{s.icon} shrink-0 text-base"></div>
{@render terminalIcon(s.icon, 'text-base')}
{/if}
{s.command}{s.args.length ? ` ${s.args.join(' ')}` : ''}
</span>
Expand Down
10 changes: 5 additions & 5 deletions plugins/terminals/src/node/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,16 @@ const HUB_STATUS: Record<TerminalStatus, HubTerminalEntry['status']> = {
}

/**
* Normalize a hub dock icon (`ph:code-duotone`, or a light/dark pair) to the
* UnoCSS `preset-icons` class the client renders (`i-ph-code-duotone`). The
* client can only render icons the SPA's UnoCSS build statically emitted (see
* the safelist in `uno.config.ts`), so unknown icons resolve to `undefined`.
* Preserve explicit masks; normalize other hub icons (`ph:code-duotone`) to
* UnoCSS classes (`i-ph-code-duotone`), choosing the light variant of theme pairs.
* Class icons render only if the terminal SPA's UnoCSS build includes them
* (see the safelist in `uno.config.ts`); masks load their image URL directly.
*/
function toIconClass(icon?: HubTerminalEntry['icon']): string | undefined {
const raw = typeof icon === 'string' ? icon : icon?.light
if (!raw)
return undefined
return raw.startsWith('i-') ? raw : `i-${raw.replace(':', '-')}`
return raw.startsWith('mask:') || raw.startsWith('i-') ? raw : `i-${raw.replace(':', '-')}`
}

function defaultShell(): string {
Expand Down
6 changes: 6 additions & 0 deletions plugins/terminals/test/terminals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ describe('@devframes/plugin-terminals', () => {

describe('hub aggregation', () => {
it('surfaces sessions contributed by other devframes as read-only entries', async () => {
expect.assertions(9)
await server.close()
const hub = createFakeHubTerminals()
server = await startTerminalsServer({}, { hub })
Expand All @@ -364,6 +365,11 @@ describe('@devframes/plugin-terminals', () => {
// Its output is read from the hub's streaming channel, not the plugin's.
expect(cs?.channel).toBe('devframe:terminals')

const mask = 'mask:data:image/svg+xml,%3Csvg%2F%3E'
hub.update({ id: 'devframes_plugin_code-server', icon: mask })
const masked = await call<TerminalSessionInfo[]>(client, 'devframes:plugin:terminals:list')
expect(masked.find(session => session.id === 'devframes_plugin_code-server')?.icon).toBe(mask)

// A stopped hub session maps onto the plugin's 'exited' status.
hub.update({ id: 'devframes_plugin_code-server', status: 'stopped' })
const afterStop = await call<TerminalSessionInfo[]>(client, 'devframes:plugin:terminals:list')
Expand Down
Loading