diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index eadd9b2a..529fb459 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -14,6 +14,7 @@ const DevToolsKitNav = [ { text: 'Dock System', link: '/kit/dock-system' }, { text: 'RPC', link: '/kit/rpc' }, { text: 'Shared State', link: '/kit/shared-state' }, + { text: 'Logs & Notifications', link: '/kit/logs' }, ] const SocialLinks = [ @@ -66,6 +67,7 @@ export default extendConfig(withMermaid(defineConfig({ { text: 'Dock System', link: '/kit/dock-system' }, { text: 'RPC', link: '/kit/rpc' }, { text: 'Shared State', link: '/kit/shared-state' }, + { text: 'Logs', link: '/kit/logs' }, ], }, ], diff --git a/docs/kit/logs.md b/docs/kit/logs.md new file mode 100644 index 00000000..964532fb --- /dev/null +++ b/docs/kit/logs.md @@ -0,0 +1,168 @@ +# Logs & Notifications + +The Logs system allows plugins to emit structured log entries from both the server (Node.js) and client (browser) contexts. Logs are displayed in the built-in **Logs** panel in the DevTools dock, and can optionally appear as toast notifications. + +## Use Cases + +- **Accessibility audits** — Run a11y checks or similar tools on the client side, report warnings with element positions +- **Runtime errors** — Capture and display errors with stack traces +- **Linting & testing** — Run ESLint or test runners alongside the dev server and surface results with file positions +- **Notifications** — Short-lived messages like "URL copied" that auto-dismiss + +## Log Entry Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `message` | `string` | Yes | Short title or summary | +| `level` | `'info' \| 'warn' \| 'error' \| 'success' \| 'debug'` | Yes | Severity level, determines color and icon | +| `description` | `string` | No | Detailed description or explanation | +| `stacktrace` | `string` | No | Stack trace string | +| `filePosition` | `{ file, line?, column? }` | No | Source file location (clickable in the panel) | +| `elementPosition` | `{ selector?, boundingBox?, description? }` | No | DOM element position info | +| `notify` | `boolean` | No | Show as a toast notification | +| `category` | `string` | No | Grouping category (e.g., `'a11y'`, `'lint'`) | +| `labels` | `string[]` | No | Tags for filtering | +| `autoDismiss` | `number` | No | Time in ms to auto-dismiss the toast (default: 5000) | +| `autoDelete` | `number` | No | Time in ms to auto-delete the log entry | +| `status` | `'loading' \| 'idle'` | No | Status indicator (shows spinner when `'loading'`) | +| `id` | `string` | No | Explicit id for deduplication — re-adding with the same id updates the existing entry | + +The `source` field is automatically set to `'server'` or `'client'` depending on where the log was emitted. + +## Usage + +Both server-side and client-side share the same `context.logs` API. All methods return Promises, but you don't need to `await` them for fire-and-forget usage. + +### Fire-and-Forget + +```ts +// No await needed — just emit the log +context.logs.add({ + message: 'Plugin initialized', + level: 'info', +}) +``` + +### With Handle + +`await` the `add()` call to get a `DevToolsLogHandle` for subsequent updates: + +```ts +// Await to get a handle for later updates +const handle = await context.logs.add({ + id: 'my-build', + message: 'Building...', + level: 'info', + status: 'loading', +}) + +// Later, update via the handle +await handle.update({ + message: 'Build complete', + level: 'success', + status: 'idle', +}) + +// Or dismiss it +await handle.dismiss() +``` + +### Server-Side Example + +```ts +export function myPlugin() { + return { + name: 'my-plugin', + devtools: { + setup(context) { + // Fire-and-forget + context.logs.add({ + message: 'Plugin initialized', + level: 'info', + }) + }, + }, + } +} +``` + +### Client-Side Example + +```ts +import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' + +export default async function (context: DockClientScriptContext) { + // Await to get the handle + const log = await context.logs.add({ + message: 'Running audit...', + level: 'info', + status: 'loading', + notify: true, + }) + + // ... do work ... + + // Update via handle — can also be fire-and-forget + log.update({ + message: 'Audit complete — 3 issues found', + level: 'warn', + status: 'idle', + }) +} +``` + +## Log Handle + +`context.logs.add()` returns a `Promise` with: + +| Property/Method | Description | +|-----------------|-------------| +| `handle.id` | The log entry id | +| `handle.entry` | The current `DevToolsLogEntry` data | +| `handle.update(patch)` | Partially update the log entry (returns `Promise`) | +| `handle.dismiss()` | Remove the log entry (returns `Promise`) | + +Both `handle.update()` and `handle.dismiss()` return Promises but can be used without `await` for fire-and-forget. + +## Deduplication + +When you call `add()` with an explicit `id` that already exists, the existing entry is **updated** instead of duplicated. This is useful for logs that represent ongoing operations: + +```ts +// First call creates the entry +context.logs.add({ id: 'my-scan', message: 'Scanning...', level: 'info', status: 'loading' }) + +// Second call with same id updates it +context.logs.add({ id: 'my-scan', message: 'Scan complete', level: 'success', status: 'idle' }) +``` + +## Toast Notifications + +Set `notify: true` to show the log entry as a toast notification overlay. Toasts appear regardless of whether the Logs panel is open. + +```ts +context.logs.add({ + message: 'URL copied to clipboard', + level: 'success', + notify: true, + autoDismiss: 2000, // disappear after 2 seconds +}) +``` + +The default auto-dismiss time for toasts is 5 seconds. + +## Managing Logs + +```ts +// Remove a specific log by id +context.logs.remove(entryId) + +// Clear all logs +context.logs.clear() +``` + +Logs have a maximum capacity of 1000 entries. When the limit is reached, the oldest entries are automatically removed. + +## Dock Badge + +The Logs dock icon automatically shows a badge with the total log count. The icon is hidden when there are no logs. diff --git a/examples/plugin-a11y-checker/package.json b/examples/plugin-a11y-checker/package.json new file mode 100644 index 00000000..795cf69b --- /dev/null +++ b/examples/plugin-a11y-checker/package.json @@ -0,0 +1,34 @@ +{ + "name": "example-plugin-a11y-checker", + "type": "module", + "version": "0.0.0-alpha.34", + "private": true, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build:node": "tsdown --config-loader=tsx", + "build": "pnpm run build:node", + "play:dev": "pnpm run build && cd playground && DEBUG='vite:devtools:*' vite" + }, + "peerDependencies": { + "vite": "*" + }, + "dependencies": { + "@vitejs/devtools": "workspace:*", + "@vitejs/devtools-kit": "workspace:*", + "axe-core": "catalog:devtools" + }, + "devDependencies": { + "solid-js": "catalog:devtools", + "tsdown": "catalog:build", + "unocss": "catalog:build", + "vite": "catalog:build", + "vite-plugin-solid": "catalog:devtools" + } +} diff --git a/examples/plugin-a11y-checker/playground/index.html b/examples/plugin-a11y-checker/playground/index.html new file mode 100644 index 00000000..f13eeb21 --- /dev/null +++ b/examples/plugin-a11y-checker/playground/index.html @@ -0,0 +1,12 @@ + + + + + + A11y Checker Playground + + +
+ + + diff --git a/examples/plugin-a11y-checker/playground/src/App.tsx b/examples/plugin-a11y-checker/playground/src/App.tsx new file mode 100644 index 00000000..83baed40 --- /dev/null +++ b/examples/plugin-a11y-checker/playground/src/App.tsx @@ -0,0 +1,73 @@ +// @unocss-include + +/** + * This component has intentional accessibility issues + * for testing the A11y Checker plugin. + */ +export default function App() { + return ( +
+
+
+

A11y Checker Playground

+

+ Open Vite DevTools and click the + {' '} + A11y Checker + {' '} + icon + (wheelchair) to run an accessibility audit on this page. + The results will appear in the + {' '} + Logs + {' '} + panel. +

+
+ + {/* Intentional a11y issues below */} + +
+

Test Cases

+ + {/* Issue: image without alt */} +
+

Image without alt text

+ +
+ + {/* Issue: button with no accessible name */} +
+

Button without label

+
+ + {/* Issue: low contrast text */} +
+

Low contrast text

+

+ This text has very low contrast and is hard to read. +

+
+ + {/* Issue: form input without label */} +
+

Input without label

+ +
+ + {/* Issue: clickable div without role */} +
+

Clickable div without role

+
{}} + class="cursor-pointer bg-purple-100 dark:bg-purple-900 rounded px-3 py-2 inline-block" + > + Click me (I'm a div, not a button) +
+
+
+
+
+ ) +} diff --git a/examples/plugin-a11y-checker/playground/src/main.tsx b/examples/plugin-a11y-checker/playground/src/main.tsx new file mode 100644 index 00000000..fc5098eb --- /dev/null +++ b/examples/plugin-a11y-checker/playground/src/main.tsx @@ -0,0 +1,11 @@ +/* @refresh reload */ +import { render } from 'solid-js/web' +import App from './App' +import '@unocss/reset/tailwind.css' +import 'virtual:uno.css' + +const root = document.getElementById('app') +if (!root) + throw new Error('Missing #app root') + +render(() => , root) diff --git a/examples/plugin-a11y-checker/playground/src/vite-env.d.ts b/examples/plugin-a11y-checker/playground/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/plugin-a11y-checker/playground/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/plugin-a11y-checker/playground/vite.config.ts b/examples/plugin-a11y-checker/playground/vite.config.ts new file mode 100644 index 00000000..c3d31973 --- /dev/null +++ b/examples/plugin-a11y-checker/playground/vite.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath } from 'node:url' +import { DevTools } from '@vitejs/devtools' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' +import { A11yCheckerPlugin } from '../src/node' + +const unoConfig = fileURLToPath(new URL('../uno.config.ts', import.meta.url)) + +export default defineConfig({ + plugins: [ + DevTools({ + builtinDevTools: false, + }), + solid(), + A11yCheckerPlugin(), + UnoCSS({ + configFile: unoConfig, + }), + ], +}) diff --git a/examples/plugin-a11y-checker/src/client/run-axe.ts b/examples/plugin-a11y-checker/src/client/run-axe.ts new file mode 100644 index 00000000..68587da4 --- /dev/null +++ b/examples/plugin-a11y-checker/src/client/run-axe.ts @@ -0,0 +1,81 @@ +import type { DevToolsLogLevel } from '@vitejs/devtools-kit' +import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' +import axe from 'axe-core' + +const SUMMARY_LOG_ID = 'a11y-checker-summary' + +function impactToLevel(impact: string | undefined | null): DevToolsLogLevel { + switch (impact) { + case 'critical': + case 'serious': + return 'error' + case 'moderate': + return 'warn' + case 'minor': + default: + return 'info' + } +} + +export default async function runA11yCheck(context: DockClientScriptContext): Promise { + const { logs } = context + + // Show loading state + const summary = await logs.add({ + id: SUMMARY_LOG_ID, + message: 'Running accessibility audit...', + level: 'info', + category: 'a11y', + status: 'loading', + notify: true, + }) + + try { + const results = await axe.run(document) + + for (const violation of results.violations) { + const level = impactToLevel(violation.impact) + const firstNode = violation.nodes[0] + + // Fire-and-forget — no need to await when handle isn't needed + logs.add({ + id: `a11y-violation-${violation.id}`, + message: violation.description, + level, + description: `${violation.help}\n\n${violation.helpUrl}`, + category: 'a11y', + labels: violation.tags.filter(t => t.startsWith('wcag') || t.startsWith('best-practice')), + elementPosition: firstNode + ? { + selector: firstNode.target.join(' '), + description: firstNode.html, + } + : undefined, + }) + } + + const violationCount = results.violations.length + const passCount = results.passes.length + + // Update the summary log via handle + await summary.update({ + message: violationCount > 0 + ? `Found ${violationCount} violation${violationCount > 1 ? 's' : ''}, ${passCount} passed` + : `All ${passCount} checks passed`, + level: violationCount > 0 ? 'warn' : 'success', + status: 'idle', + notify: true, + autoDismiss: 4000, + }) + } + catch (err) { + // Update the summary log with error via handle + await summary.update({ + message: 'A11y audit failed', + level: 'error', + description: String(err), + status: 'idle', + notify: true, + }) + } +} diff --git a/examples/plugin-a11y-checker/src/node/index.ts b/examples/plugin-a11y-checker/src/node/index.ts new file mode 100644 index 00000000..14970843 --- /dev/null +++ b/examples/plugin-a11y-checker/src/node/index.ts @@ -0,0 +1 @@ +export { A11yCheckerPlugin } from './plugin' diff --git a/examples/plugin-a11y-checker/src/node/plugin.ts b/examples/plugin-a11y-checker/src/node/plugin.ts new file mode 100644 index 00000000..84dd9ee4 --- /dev/null +++ b/examples/plugin-a11y-checker/src/node/plugin.ts @@ -0,0 +1,41 @@ +import type { PluginWithDevTools } from '@vitejs/devtools-kit' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { normalizePath } from 'vite' + +function resolveClientScript(): string { + const distFromBundle = fileURLToPath(new URL('./client/run-axe.js', import.meta.url)) + const distFromSource = fileURLToPath(new URL('../../dist/client/run-axe.js', import.meta.url)) + return fs.existsSync(distFromBundle) ? distFromBundle : distFromSource +} + +export function A11yCheckerPlugin(): PluginWithDevTools { + return { + name: 'plugin-a11y-checker-devtools', + devtools: { + setup(context) { + const clientScript = resolveClientScript() + + context.docks.register({ + type: 'action', + id: 'a11y-checker', + title: 'Run A11y Check', + icon: 'ph:wheelchair-duotone', + category: 'web', + action: { + importFrom: `/@fs/${normalizePath(clientScript)}`, + }, + }) + + context.logs.add({ + message: 'A11y Checker ready — click the icon to run an audit', + level: 'info', + notify: true, + autoDismiss: 3000, + autoDelete: 10000, + category: 'a11y', + }) + }, + }, + } +} diff --git a/examples/plugin-a11y-checker/tsconfig.json b/examples/plugin-a11y-checker/tsconfig.json new file mode 100644 index 00000000..32dd7d0c --- /dev/null +++ b/examples/plugin-a11y-checker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "declarationMap": true, + "noEmit": true, + "sourceMap": true, + "esModuleInterop": true, + "isolatedDeclarations": false + } +} diff --git a/examples/plugin-a11y-checker/tsdown.config.ts b/examples/plugin-a11y-checker/tsdown.config.ts new file mode 100644 index 00000000..2b4e495c --- /dev/null +++ b/examples/plugin-a11y-checker/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig([ + { + entry: { + index: 'src/node/index.ts', + }, + clean: true, + dts: true, + format: 'esm', + platform: 'node', + exports: true, + }, + { + entry: { + 'client/run-axe': 'src/client/run-axe.ts', + }, + format: 'esm', + platform: 'browser', + external: ['axe-core', '@vitejs/devtools-kit', '@vitejs/devtools-kit/client'], + }, +]) diff --git a/examples/plugin-a11y-checker/uno.config.ts b/examples/plugin-a11y-checker/uno.config.ts new file mode 100644 index 00000000..b5373eb7 --- /dev/null +++ b/examples/plugin-a11y-checker/uno.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, presetWind3 } from 'unocss' + +export default defineConfig({ + presets: [ + presetWind3({ + dark: 'media', + }), + ], +}) diff --git a/packages/core/playground/src/main.ts b/packages/core/playground/src/main.ts index 22cd5030..b1f50595 100644 --- a/packages/core/playground/src/main.ts +++ b/packages/core/playground/src/main.ts @@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router' import { routes } from 'vue-router/auto-routes' import App from './App.vue' +import 'virtual:uno.css' import './style.css' const router = createRouter({ diff --git a/packages/core/playground/src/pages/devtools.vue b/packages/core/playground/src/pages/devtools.vue index d69b537f..e2cbe363 100644 --- a/packages/core/playground/src/pages/devtools.vue +++ b/packages/core/playground/src/pages/devtools.vue @@ -1,42 +1,297 @@