Skip to content

Commit 849671e

Browse files
dvcolombanantfubotantfu
authored
feat(hub-ui): render explicit SVG mask icons (#382)
Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me> Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 6662519 commit 849671e

9 files changed

Lines changed: 93 additions & 12 deletions

File tree

docs/content/1.guide/2.devframe-definition.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default defineDevframe({
3939

4040
## Definition fields
4141

42-
`id`, `name`, `version`, `packageName`, `homepage`, `description`, and `setup` are required; pass `importMetaUrl: import.meta.url` so [remote assets](/guide/client-assets) and declared [services](/guide/services#wire-services) resolve against the devframe's own dependencies. The remaining fields cover display (`icon`), mounting (`basePath`, `duplicationStrategy`, `capabilities`), what the devframe consumes and serves (`services`, `clientAssets`, `rpc.snapshot`), and [CLI defaults](#cli-options) (`cli`). Every field is listed in the [Node-Side API reference](/references/node-api#definition-fields).
42+
`id`, `name`, `version`, `packageName`, `homepage`, `description`, and `setup` are required; pass `importMetaUrl: import.meta.url` so [remote assets](/guide/client-assets) and declared [services](/guide/services#wire-services) resolve against the devframe's own dependencies. The remaining fields cover display (`icon`, [Icon values](/references/node-api#icon-values)), mounting (`basePath`, `duplicationStrategy`, `capabilities`), what the devframe consumes and serves (`services`, `clientAssets`, `rpc.snapshot`), and [CLI defaults](#cli-options) (`cli`). Every field is listed in the [Node-Side API reference](/references/node-api#definition-fields).
4343

4444
### Sourcing metadata from `package.json`
4545

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

Lines changed: 15 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, light/dark pairs, etc. See [Icon values](#icon-values). |
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,20 @@ 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+
## Icon values
34+
35+
The `icon` field, and every dock entry, command, and terminal icon, accept the same string forms. A `{ light, dark }` pair picks the matching variant per active color scheme, and each side takes any of these forms:
36+
37+
| Form | Example | Rendered as |
38+
|---|---|---|
39+
| Iconify name | `ph:gauge-duotone`, `logos:nuxt-icon` | the `collection:icon` glyph, fetched from Iconify as inline SVG |
40+
| Image URL | `/icons/logo.svg`, `https://example.com/logo.png` | an `<img>`; relative URLs resolve against the supplying hub |
41+
| Data URL | `data:image/svg+xml,%3Csvg…%3E` | an inline `<img>` from the embedded data |
42+
| SVG mask | `mask:/icons/logo.svg`, `mask:data:image/svg+xml,%3Csvg…%3E` | an alpha mask tinted with `currentColor`, following the surrounding text color |
43+
| Light/dark pair | `{ light: './logo-light.svg', dark: './logo-dark.svg' }` | the variant matching the active color scheme; each side is any string form above |
44+
45+
The reference hub UI and terminal SPA render `mask:` icons with the surrounding text color, preserving the image's shape and opacity, so a bundled monochrome SVG follows the dock's foreground color through selected and dimmed states. 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.
46+
3347
## CLI options
3448

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

docs/content/8.references/6.hub-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ The origin-locked `postMessage` protocol on `devframe:frame-nav`: [Shared-iframe
145145

146146
## Dock entry types
147147

148-
The built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`) a hub UI provider renders: [Build Your Own Hub UI](/guide/build-your-own-hub-ui).
148+
The built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`) a hub UI provider renders: [Build Your Own Hub UI](/guide/build-your-own-hub-ui). Each entry's `icon` takes any of the [icon values](/references/node-api#icon-values).
149149

150150
| Type | The hub UI provider renders |
151151
|---|---|

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
<script setup lang="ts">
2+
import type { CSSProperties } from 'vue'
23
import { computed, ref, watchEffect } from 'vue'
34
import { getIconifySvg } from '../../utils/iconify'
45
56
const props = defineProps<{
67
icon: string
78
}>()
89
9-
const isUrlIcon = computed(() => props.icon.includes('/') || props.icon.startsWith('data:') || props.icon.startsWith('builtin:'))
10+
const maskUrl = computed(() => props.icon.startsWith('mask:') ? props.icon.slice(5).trim() : undefined)
11+
const maskStyle = computed<CSSProperties | undefined>(() => {
12+
if (!maskUrl.value)
13+
return undefined
14+
return {
15+
backgroundColor: 'currentColor',
16+
mask: `url(${JSON.stringify(maskUrl.value)}) center / contain no-repeat`,
17+
maskMode: 'alpha',
18+
}
19+
})
20+
const isUrlIcon = computed(() => maskUrl.value !== undefined || props.icon.includes('/') || props.icon.startsWith('data:') || props.icon.startsWith('builtin:'))
1021
const iconifyParsed = computed(() => {
1122
if (isUrlIcon.value)
1223
return undefined
@@ -45,6 +56,12 @@ watchEffect(async (onCleanup) => {
4556

4657
<template>
4758
<div v-if="failed" class="i-ph:warning-duotone w-full h-full" aria-hidden="true" />
59+
<div
60+
v-else-if="maskUrl !== undefined"
61+
aria-hidden="true"
62+
class="w-full h-full"
63+
:style="maskStyle"
64+
/>
4865
<div
4966
v-else-if="iconifyParsed"
5067
aria-hidden="true"

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,24 @@ 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+
42+
it('trims mask URLs and preserves empty masks without resolving the metadata URL', () => {
43+
expect.assertions(4)
44+
expect(resolveDockIcon('mask: ./icon.svg ', connection)).toBe('mask:http://localhost:5173/__devtools/icon.svg')
45+
expect(resolveDockIcon('mask: data:image/svg+xml,%3Csvg%2F%3E ', connection)).toBe('mask:data:image/svg+xml,%3Csvg%2F%3E')
46+
expect(resolveDockIcon('mask:', connection)).toBe('mask:')
47+
expect(resolveDockIcon('mask: ', connection)).toBe('mask:')
48+
})
49+
3250
it('resolves light and dark icon variants independently', () => {
3351
expect(resolveDockIcon({
3452
light: './icons/light.svg',

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ 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+
const maskUrl = url.slice(5).trim()
26+
if (!maskUrl)
27+
return 'mask:'
28+
try {
29+
return `mask:${new URL(maskUrl, connection.metaBaseUrl).href}`
30+
}
31+
catch {
32+
return url
33+
}
34+
}
2435
if (!url || URL_SCHEME_RE.test(url) || url.startsWith('//'))
2536
return url
2637

plugins/terminals/app/client/App.svelte

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

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

354+
{#snippet terminalIcon(icon: string, className = '')}
355+
{#if icon.startsWith('mask:')}
356+
{@const maskUrl = icon.slice(5).trim()}
357+
<div
358+
class="shrink-0 w-1em h-1em {className}"
359+
aria-hidden="true"
360+
style:background-color={maskUrl ? 'currentColor' : 'transparent'}
361+
style:mask={maskUrl ? `url(${JSON.stringify(maskUrl)}) center / contain no-repeat` : 'none'}
362+
style:mask-mode="alpha"
363+
></div>
364+
{:else}
365+
<div class="{icon} shrink-0 {className}" aria-hidden="true"></div>
366+
{/if}
367+
{/snippet}
368+
354369
{#if connCopy}
355370
<div class={connectionPanel('absolute inset-0 color-base font-sans')}>
356371
<div class="{connCopy.icon} {connectionGlyph(connCopy.spin)}"></div>
@@ -399,7 +414,7 @@
399414
>
400415
<span class={dot(statusDot(s.status))}></span>
401416
{#if s.icon}
402-
<div class="{s.icon} shrink-0"></div>
417+
{@render terminalIcon(s.icon)}
403418
{/if}
404419
<span class="truncate">{displayName(s)}</span>
405420
<span
@@ -462,7 +477,7 @@
462477
class="flex items-center gap-2 px2 py1.5 rounded text-sm text-left op-fade hover:(op100 bg-active) transition-colors"
463478
onclick={() => runPreset(p.id)}
464479
>
465-
<div class="{p.icon || 'i-ph-terminal-duotone'} shrink-0 op-fade"></div>
480+
{@render terminalIcon(p.icon || 'i-ph-terminal-duotone', 'op-fade')}
466481
<span class="truncate flex-1">{p.title}</span>
467482
<span class="font-mono text-xs op-mute">{p.mode === 'interactive' ? 'tty' : 'log'}</span>
468483
</button>
@@ -482,7 +497,7 @@
482497
</span>
483498
<span class="font-mono truncate op-fade flex items-center gap-1.5" title={`${s.command} ${s.args.join(' ')}`}>
484499
{#if s.icon}
485-
<div class="{s.icon} shrink-0 text-base"></div>
500+
{@render terminalIcon(s.icon, 'text-base')}
486501
{/if}
487502
{s.command}{s.args.length ? ` ${s.args.join(' ')}` : ''}
488503
</span>

plugins/terminals/src/node/manager.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,16 @@ const HUB_STATUS: Record<TerminalStatus, HubTerminalEntry['status']> = {
9595
}
9696

9797
/**
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`.
98+
* Preserve explicit masks; normalize other hub icons (`ph:code-duotone`) to
99+
* UnoCSS classes (`i-ph-code-duotone`), choosing the light variant of theme pairs.
100+
* Class icons render only if the terminal SPA's UnoCSS build includes them
101+
* (see the safelist in `uno.config.ts`); masks load their image URL directly.
102102
*/
103103
function toIconClass(icon?: HubTerminalEntry['icon']): string | undefined {
104104
const raw = typeof icon === 'string' ? icon : icon?.light
105105
if (!raw)
106106
return undefined
107-
return raw.startsWith('i-') ? raw : `i-${raw.replace(':', '-')}`
107+
return raw.startsWith('mask:') || raw.startsWith('i-') ? raw : `i-${raw.replace(':', '-')}`
108108
}
109109

110110
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)