vanilla-widget Example
examples/vanilla-widget is the official example for the vanilla-ts template: plain TypeScript, no framework, zero Vite configuration. Its structure matches the project generated by npx create-patab-widget my-widget --template vanilla-ts.
Directory Layout
vanilla-widget/
├── patab.manifest.json
├── schema/patab.manifest.schema.json
├── package.json
├── tsconfig.json
├── assets/
│ ├── icon.png
│ └── screenshots/preview.png
└── src/
├── env.d.ts # /// <reference types="vite/client" />
├── widget.spec.ts # Mock 宿主单元测试
└── surfaces/
├── widget.html / widget.ts
├── detail.html / detail.ts
└── settings.html / settings.ts
Manifest Highlights
{
"id": "com.example.examples-vanilla-widget",
"surfaces": {
"widget": { "entry": "surfaces/widget.html", "title": { "default": "组件", "...": "..." } },
"detail": { "entry": "surfaces/detail.html", "title": { "...": "..." }, "modalSize": "medium" },
"settings": { "entry": "surfaces/settings.html", "title": { "...": "..." }, "modalSize": "small" }
},
"sizes": [{ "w": 2, "h": 2 }, { "w": 3, "h": 2 }],
"defaultSize": { "w": 2, "h": 2 },
"variants": [
{ "id": "compact", "supportedSizes": [{ "w": 2, "h": 2 }], "...": "..." },
{ "id": "expanded", "supportedSizes": [{ "w": 3, "h": 2 }], "...": "..." }
],
"defaultVariant": "compact",
"permissions": { "optional": ["todos.read"] }
}
It demonstrates three surfaces (a widget tile plus detail/settings modals), two sizes, two variants, and how to declare the optional permission todos.read.
Entry HTML
Each surface has a minimal HTML skeleton containing only a mount point and a module script:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>widget</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="./widget.ts"></script>
</body>
</html>
At packing time the CLI inlines the build output of ./widget.ts into this HTML, producing a fully self-contained surfaces/widget.html.
Entry TS Walkthrough
import '@patab/widget-sdk/theme.css'
import { connectPatabWidgetClient, createWidgetApi, type WidgetThemeChangedEvent } from '@patab/widget-sdk'
async function start(): Promise<void> {
// 1. 连接宿主:等待 MessagePort 握手完成
const client = await connectPatabWidgetClient()
const api = createWidgetApi(client)
// 2. 读取上下文:surface 名、variant、主题、权限快照
const context = await api.context.get()
const root = document.querySelector<HTMLElement>('#app')
if (!root) return
root.innerHTML = '<h1>' + context.surface + '</h1><p>' + (context.variantId ?? 'default') + '</p><button class="pt-button">保存示例</button>'
// 3. 实例存储:点击按钮把当前 surface 写入实例私有存储
root.querySelector('button')?.addEventListener('click', () => {
void api.storage.set('lastSurface', context.surface)
})
// 4. 事件订阅:主题变化时更新根节点标记
api.on<WidgetThemeChangedEvent>('themeChanged', (event) => {
document.documentElement.dataset.theme = event.theme
})
// 5. 可选权限:先检查 granted 快照再调用,未授权时静默降级
if (context.permissions.granted.includes('todos.read')) {
void api.todos.list().catch(() => undefined)
}
}
void start()
Point by point:
- Connection:
connectPatabWidgetClient()is the only entry point; the widget never toucheswindow.parent/postMessage. - Context:
context.get()provides the environment information needed for rendering (here it renders the surface name and variant ID). - Storage:
storage.setdemonstrates instance-private storage — data from different instances (the same widget added to the grid multiple times) is mutually invisible. - Theme: importing
theme.cssprovides thept-*semantic classes (the button uses.pt-button), and subscribing tothemeChangedenables custom responses. Note thatpt-card/pt-title/pt-mutedin the example are not SDK contract classes; in real projects use the semantic classes listed in theme.css. - Permission degradation:
todos.readis an optional permission — the user may not have granted it or may revoke it at any time. Checking the snapshot first and then falling back with.catch()is the recommended pattern (calls after revocation returnPERMISSION_DENIED).
Unit Tests
import { expect, it } from 'vitest'
import { createWidgetMockHost } from '@patab/widget-sdk/testing'
it('读取 Mock 存储', async () => {
const host = createWidgetMockHost({ storage: { greeting: 'PaTab' } })
await expect(host.api.storage.get('greeting')).resolves.toEqual({ found: true, value: 'PaTab' })
host.close()
})
Uses the Mock Host to inject initial storage and asserts read results over the real RPC protocol. Run pnpm test (vitest run).
Running Locally
cd examples/vanilla-widget
pnpm install
pnpm dev # Open the real PaTab development host and mount the widget
pnpm check && pnpm run pack && pnpm exec patab-widget inspect dist/*.patab.zip