Skip to content

Commit 02b909e

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

11 files changed

Lines changed: 171 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).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import process from 'node:process'
2+
import { defineConfig } from '@playwright/test'
3+
4+
export default defineConfig({
5+
testDir: './tests',
6+
testMatch: '*.browser.ts',
7+
use: { baseURL: 'http://127.0.0.1:5199', viewport: { width: 1280, height: 800 } },
8+
webServer: {
9+
command: 'pnpm exec vite --config playground/vite.config.ts --host 127.0.0.1 --port 5199 --strictPort',
10+
url: 'http://127.0.0.1:5199',
11+
reuseExistingServer: !process.env.CI,
12+
env: { DEVFRAME_DISABLE_INSTANCE_REGISTRY: '1' },
13+
},
14+
})

‎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
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as IconifyIcon } from '../src/client/components/icons/IconifyIcon.vue'
2+
export { getIconifySvg } from '../src/client/utils/iconify'
3+
export { createApp, h, nextTick, ref } from 'vue'
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { expect, test } from '@playwright/test'
3+
4+
const fixtureUrl = `/@fs${fileURLToPath(new URL('./icons.fixture.ts', import.meta.url))}`
5+
const contextUrl = `/@fs${fileURLToPath(new URL('../../hub/src/client/context.ts', import.meta.url))}`
6+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 12l5 5L20 6" stroke="currentColor" stroke-width="3"/><circle cx="5" cy="5" r="3" opacity=".4"/></svg>'
7+
const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`
8+
const mask = `mask:${dataUrl}`
9+
10+
test('mask data bypasses fetching and ordinary images keep their rendering', async ({ page }) => {
11+
await page.route('https://api.iconify.design/**', route => route.abort())
12+
await page.goto('/')
13+
await expect(page.getByRole('button', { name: 'Settings', exact: true })).toBeVisible()
14+
const result = await page.evaluate(async ({ url, dataUrl, mask }) => {
15+
const { createApp, h, ref, nextTick, IconifyIcon } = await import(url)
16+
const originalFetch = window.fetch
17+
let calls = 0
18+
window.fetch = async () => {
19+
calls++
20+
throw new Error('No network expected')
21+
}
22+
const source = ref(mask)
23+
const container = document.createElement('div')
24+
document.body.append(container)
25+
container.style.color = 'rgb(10, 20, 30)'
26+
const application = createApp({ render: () => h(IconifyIcon, { icon: source.value }) })
27+
application.mount(container)
28+
const style = getComputedStyle(container.firstElementChild!)
29+
const masked = { image: style.maskImage, color: style.backgroundColor, mode: style.maskMode }
30+
source.value = dataUrl
31+
await nextTick()
32+
const image = container.querySelector('img')?.src
33+
application.unmount()
34+
container.remove()
35+
window.fetch = originalFetch
36+
return { masked, image, calls }
37+
}, { url: fixtureUrl, dataUrl, mask })
38+
expect(result.masked.image).toContain(dataUrl)
39+
expect(result.masked.color).toBe('rgb(10, 20, 30)')
40+
expect(result.masked.mode).toBe('alpha')
41+
expect(result.image).toBe(dataUrl)
42+
expect(result.calls).toBe(0)
43+
})
44+
45+
for (const mode of ['standalone', 'embedded']) {
46+
for (const colorScheme of ['light', 'dark'] as const) {
47+
test(`mask states screenshot: ${mode}, ${colorScheme}`, async ({ page }, testInfo) => {
48+
await page.emulateMedia({ colorScheme })
49+
await page.route('https://api.iconify.design/**', route => route.abort())
50+
await page.goto(mode === 'embedded' ? '/?embedded' : '/')
51+
await expect(page.getByRole('button', { name: 'Settings', exact: true })).toBeVisible()
52+
await page.evaluate(async ({ url, mask, dataUrl }) => {
53+
const { getDevframeClientContext } = await import(url)
54+
const context = getDevframeClientContext()!
55+
context.docks.register({
56+
id: 'test:mask',
57+
type: 'launcher',
58+
title: 'Local mask',
59+
icon: mask,
60+
launcher: { title: 'Local mask launcher', icon: mask, description: 'Bundled SVG with inherited foreground color' },
61+
})
62+
context.docks.register({
63+
id: 'test:themed',
64+
type: 'launcher',
65+
title: 'Theme pair',
66+
icon: { light: mask, dark: `mask:${dataUrl.replace('opacity%3D%22.4', 'opacity%3D%22.8')}` },
67+
launcher: { title: 'Themed launcher' },
68+
})
69+
context.commands.register({ id: 'test:mask-command', title: 'Local mask command', icon: mask, handler: () => {} })
70+
}, { url: contextUrl, mask, dataUrl })
71+
const button = page.getByRole('button', { name: 'Local mask', exact: true })
72+
const icon = button.locator('[style*="mask:"]')
73+
await expect(icon).toBeVisible()
74+
await button.click()
75+
await expect(page.getByText('Local mask launcher', { exact: true })).toBeVisible()
76+
await expect(button).toHaveClass(/text-primary/)
77+
await expect.poll(() => icon.evaluate(element => getComputedStyle(element).backgroundColor === getComputedStyle(element).color)).toBe(true)
78+
const themed = page.getByRole('button', { name: 'Theme pair', exact: true })
79+
if (mode === 'embedded')
80+
await expect(themed).toHaveClass(/op50/)
81+
await expect(themed.locator('[style*="mask:"]:visible')).toHaveAttribute('style', new RegExp(colorScheme === 'dark' ? 'opacity%3D%22.8' : 'opacity%3D%22.4'))
82+
await button.hover()
83+
await page.screenshot({ animations: 'disabled', path: testInfo.outputPath(`${mode}-${colorScheme}-masks.png`) })
84+
await page.keyboard.press('ControlOrMeta+k')
85+
await page.getByPlaceholder('Type a command...').fill('Local mask command')
86+
await expect(page.locator('[id="cmd-test:mask-command"] [style*="mask:"]')).toBeVisible()
87+
await page.screenshot({ animations: 'disabled', path: testInfo.outputPath(`${mode}-${colorScheme}-command.png`) })
88+
})
89+
}
90+
}

‎packages/hub-ui/tests/masks.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Reproduce mask icons
2+
3+
```sh
4+
pnpm exec turbo run build --filter=@devframes/plugin-git...
5+
pnpm exec playwright install chromium
6+
pnpm exec playwright test --config packages/hub-ui/playwright.config.ts --grep mask
7+
```
8+
9+
The existing hub-ui playground starts automatically. The browser checks block Iconify, register local data-URL masks, and capture the dock rail, launcher and command palette in light/dark modes, in the embedded dock and standalone SPA. Screenshots use a fixed 1280×800 viewport and appear in `packages/hub-ui/test-results/`. The selected icon uses the primary foreground; unselected icons retain the dock's dimming.

‎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 {

0 commit comments

Comments
 (0)