Skip to content

Commit 13e57cf

Browse files
committed
feat(hub-ui): render explicit SVG mask icons
1 parent 7405628 commit 13e57cf

7 files changed

Lines changed: 55 additions & 13 deletions

File tree

docs/content/8.references/4.node-api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The fields of a `DevframeDefinition`: [Devframe Definition](/guide/devframe-defi
2020
| `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). |
2121
| `homepage` | `string` | **Required.** Homepage/docs URL. |
2222
| `description` | `string` | **Required.** One-line summary. |
23-
| `icon` | `string \| { light, dark }` | Optional Iconify name or URL; light/dark pairs. |
23+
| `icon` | `string \| { light, dark }` | Optional Iconify name, image URL, or `mask:<image-url>`; light/dark pairs. |
2424
| `basePath` | `string` | Optional mount-path override. Default `/` standalone (`cli`/`build`), `/__<id>/` hosted (`vite`/`embedded`). |
2525
| `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. |
2626
| `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. `boolean` = whole runtime; object = individual features. |
@@ -30,6 +30,8 @@ The fields of a `DevframeDefinition`: [Devframe Definition](/guide/devframe-defi
3030
| `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`. |
3131
| `cli` | `DevframeCliOptions` | CLI adapter defaults. See [CLI options](#cli-options). |
3232

33+
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.
34+
3335
## CLI options
3436

3537
The `cli` field's `DevframeCliOptions`: [CLI options](/guide/devframe-definition#cli-options).

packages/hub-ui/src/client/components/icons/IconifyIcon.vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ const props = defineProps<{
66
icon: string
77
}>()
88
9-
const isUrlIcon = computed(() => props.icon.includes('/') || props.icon.startsWith('data:') || props.icon.startsWith('builtin:'))
9+
const maskUrl = computed(() => props.icon.startsWith('mask:') ? props.icon.slice(5) : undefined)
10+
const isUrlIcon = computed(() => maskUrl.value !== undefined || props.icon.includes('/') || props.icon.startsWith('data:') || props.icon.startsWith('builtin:'))
1011
const iconifyParsed = computed(() => {
1112
if (isUrlIcon.value)
1213
return undefined
@@ -38,7 +39,13 @@ watchEffect(async () => {
3839

3940
<template>
4041
<div
41-
v-if="iconifyParsed"
42+
v-if="maskUrl !== undefined"
43+
aria-hidden="true"
44+
class="w-full h-full"
45+
:style="{ backgroundColor: 'currentColor', mask: `url(${JSON.stringify(maskUrl)}) center / contain no-repeat`, maskMode: 'alpha' }"
46+
/>
47+
<div
48+
v-else-if="iconifyParsed"
4249
v-html="iconifyLoaded"
4350
/>
4451
<img

packages/hub/src/client/dock-resources.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ describe('dock resource resolution', () => {
2929
expect(resolveDockIcon('data:image/svg+xml;base64,abc', connection)).toBe('data:image/svg+xml;base64,abc')
3030
})
3131

32+
it('resolves mask URLs and preserves mask data through JSON transport', () => {
33+
expect.assertions(4)
34+
const data = 'mask:data:image/svg+xml,%3Csvg%2F%3E'
35+
const icon = JSON.parse(JSON.stringify({ light: data, dark: 'mask:./dark.svg' }))
36+
expect(resolveDockIcon('mask:/icons/local.svg', connection)).toBe('mask:http://localhost:5173/icons/local.svg')
37+
expect(resolveDockIcon('mask:icon', connection)).toBe('mask:http://localhost:5173/__devtools/icon')
38+
expect(resolveDockIcon('mask:https://example.com/icon.svg', connection)).toBe('mask:https://example.com/icon.svg')
39+
expect(resolveDockIcon(icon, connection)).toEqual({ light: data, dark: 'mask:http://localhost:5173/__devtools/dark.svg' })
40+
})
41+
3242
it('resolves light and dark icon variants independently', () => {
3343
expect(resolveDockIcon({
3444
light: './icons/light.svg',

packages/hub/src/client/dock-resources.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ function resolveResourceUrl(value: string, connection: DevframeConnection): stri
2121

2222
function resolveIconUrl(value: string, connection: DevframeConnection): string {
2323
const url = value.trim()
24+
if (url.startsWith('mask:')) {
25+
try {
26+
return `mask:${new URL(url.slice(5), connection.metaBaseUrl).href}`
27+
}
28+
catch {
29+
return url
30+
}
31+
}
2432
if (!url || URL_SCHEME_RE.test(url) || url.startsWith('//'))
2533
return url
2634

plugins/terminals/app/client/App.svelte

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,20 @@
351351

352352
<svelte:window onkeydown={onGlobalKey} />
353353

354+
{#snippet terminalIcon(icon: string, className = '')}
355+
{#if icon.startsWith('mask:')}
356+
<div
357+
class="shrink-0 w-1em h-1em {className}"
358+
aria-hidden="true"
359+
style:background-color="currentColor"
360+
style:mask={`url(${JSON.stringify(icon.slice(5))}) center / contain no-repeat`}
361+
style:mask-mode="alpha"
362+
></div>
363+
{:else}
364+
<div class="{icon} shrink-0 {className}"></div>
365+
{/if}
366+
{/snippet}
367+
354368
{#if connCopy}
355369
<div class={connectionPanel('absolute inset-0 color-base font-sans')}>
356370
<div class="{connCopy.icon} {connectionGlyph(connCopy.spin)}"></div>
@@ -399,7 +413,7 @@
399413
>
400414
<span class={dot(statusDot(s.status))}></span>
401415
{#if s.icon}
402-
<div class="{s.icon} shrink-0"></div>
416+
{@render terminalIcon(s.icon)}
403417
{/if}
404418
<span class="truncate">{displayName(s)}</span>
405419
<span
@@ -462,7 +476,7 @@
462476
class="flex items-center gap-2 px2 py1.5 rounded text-sm text-left op-fade hover:(op100 bg-active) transition-colors"
463477
onclick={() => runPreset(p.id)}
464478
>
465-
<div class="{p.icon || 'i-ph-terminal-duotone'} shrink-0 op-fade"></div>
479+
{@render terminalIcon(p.icon || 'i-ph-terminal-duotone', 'op-fade')}
466480
<span class="truncate flex-1">{p.title}</span>
467481
<span class="font-mono text-xs op-mute">{p.mode === 'interactive' ? 'tty' : 'log'}</span>
468482
</button>
@@ -482,7 +496,7 @@
482496
</span>
483497
<span class="font-mono truncate op-fade flex items-center gap-1.5" title={`${s.command} ${s.args.join(' ')}`}>
484498
{#if s.icon}
485-
<div class="{s.icon} shrink-0 text-base"></div>
499+
{@render terminalIcon(s.icon, 'text-base')}
486500
{/if}
487501
{s.command}{s.args.length ? ` ${s.args.join(' ')}` : ''}
488502
</span>

plugins/terminals/src/node/manager.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,12 @@ const HUB_STATUS: Record<TerminalStatus, HubTerminalEntry['status']> = {
9494
error: 'error',
9595
}
9696

97-
/**
98-
* Normalize a hub dock icon (`ph:code-duotone`, or a light/dark pair) to the
99-
* UnoCSS `preset-icons` class the client renders (`i-ph-code-duotone`). The
100-
* client can only render icons the SPA's UnoCSS build statically emitted (see
101-
* the safelist in `uno.config.ts`), so unknown icons resolve to `undefined`.
102-
*/
97+
/** Preserve explicit masks; normalize other hub icons to the terminal SPA's UnoCSS classes. */
10398
function toIconClass(icon?: HubTerminalEntry['icon']): string | undefined {
10499
const raw = typeof icon === 'string' ? icon : icon?.light
105100
if (!raw)
106101
return undefined
107-
return raw.startsWith('i-') ? raw : `i-${raw.replace(':', '-')}`
102+
return raw.startsWith('mask:') || raw.startsWith('i-') ? raw : `i-${raw.replace(':', '-')}`
108103
}
109104

110105
function defaultShell(): string {

plugins/terminals/test/terminals.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ describe('@devframes/plugin-terminals', () => {
338338

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

368+
const mask = 'mask:data:image/svg+xml,%3Csvg%2F%3E'
369+
hub.update({ id: 'devframes_plugin_code-server', icon: mask })
370+
const masked = await call<TerminalSessionInfo[]>(client, 'devframes:plugin:terminals:list')
371+
expect(masked.find(session => session.id === 'devframes_plugin_code-server')?.icon).toBe(mask)
372+
367373
// A stopped hub session maps onto the plugin's 'exited' status.
368374
hub.update({ id: 'devframes_plugin_code-server', status: 'stopped' })
369375
const afterStop = await call<TerminalSessionInfo[]>(client, 'devframes:plugin:terminals:list')

0 commit comments

Comments
 (0)