Widget contract (for agents)
This is the implementation contract a new widget must honour to slot into the dashboard cleanly. The dashboard's shared chrome — drag, resize, persist, palette — assumes everything below.
If you're a human looking for product-side widget documentation, see features/ instead. This page is about how to add or modify a widget, not how to use one.
Folder layout
src/components/widgets/<Kind>Widget.tsx # the React component
src/styles/widgets/<kind>.css # widget-scoped styles
src/lib/<Kind>Sim.ts (optional) # simulator-path fallbackCo-locate widget styles in styles/widgets/<kind>.css and import them from the top of the widget component. The CSS chunk is then bundled with the lazy widget chunk — it loads when the widget mounts, not on every page load.
Component shape
export interface <Kind>WidgetProps {
tileId: string;
}Per-widget state lives inside the component. Persist anything that should survive reloads under the weblogcat:<kind>:<serial>:<tileId> key — the tile id keeps sibling instances independent.
const key = `weblogcat:shell:${device.serial}:${tileId}`;Registry entry
src/lib/widgets.ts is the single source of truth for "what kinds exist." Add an entry; WidgetPalette and TileGrid consult the registry — no other edits required.
shell: {
name: 'Shell',
icon: Icons.Terminal,
desc: 'Interactive ADB shell',
comp: ShellWidget,
defaultSize: { w: 6, h: 6 },
enabled: true, // false ⇒ palette card greys out
maxInstances: undefined, // omit unless capped (Mirror is 1)
}Reading the active ADB session
import { useAdb } from '../../lib/adbContext';
const { device, adb, usingFake } = useAdb();device— the activeDeviceInfo(never null inside a connected dashboard; widgets unmount when the device disconnects).adb— the liveAdbhandle from@yume-chan/adb. Null whenusingFakeis true. Use it foradb.subprocess.shell.spawn(),adb.sync(), etc.usingFake— flip on the simulator path, e.g. swapadb.subprocesscalls for an in-memory fake.
For Logcat-style streams, prefer useLogStream() from lib/logStreamContext.ts over opening your own subprocess — there's already one upstream logcat per session and the hub fans entries out to N subscribers.
Bars-hidden / widget-bar class
The tile header has an eye toggle that flips barsHidden on the tile. The CSS rule (in src/styles/dashboard.css) hides any internal toolbar matching:
.tile.bars-hidden .widget-bar,
.tile.bars-hidden .lc-toolbar,
.tile.bars-hidden .ds-toolbar,
.tile.bars-hidden .fx-toolbar,
.tile.bars-hidden .mr-toolbar,
.tile.bars-hidden .filter-bar { display: none !important }So the convention is: every widget's top toolbar carries class widget-bar (plus its own widget-specific class for styling).
Errors and toasts
Widget errors that the user should see go through useDashboardChrome():
const { showToast } = useDashboardChrome();
try { … } catch (e) { showToast(e.message); }The toast layer is mounted once at the App root and displays for ~1.8 s. Use it sparingly — chrome-level acknowledgements only (connect / disconnect / clear / errors). Per-widget status belongs in the widget body.
Per-widget keyboard shortcuts
Global shortcuts (the help dialog ?) live in App.tsx. Widget shortcuts must be scoped to focus inside the widget — otherwise two Logcat tiles fire Cmd+F on each other. The standard pattern:
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const root = rootRef.current;
if (!root || !root.contains(document.activeElement)) return;
/* … */
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return <div ref={rootRef} tabIndex={-1} onMouseDown={focusOnClick}>{…}</div>;Per-widget settings modal
Cog in the tile header opens WidgetSettingsModal keyed by the widget kind. Widgets contribute their schema by exporting a settings definition picked up by the modal. Per-widget settings persist under weblogcat:<kind>:<serial>:<tileId>:settings.
Testing (mandatory, see test-sync)
- Pure logic (parsers, snap math) → Vitest under
src/lib/. Mirror thelib/filters.test.tsstyle — no DOM, fixtures inline. - Behaviour → Playwright e2e under
tests/. Drive the simulator path; cover at minimum:- the widget appears in the palette and is enabled;
- adding it from the palette spawns exactly one tile of that kind;
- the canonical happy-path interaction (Shell:
pwdreturns/sdcard; Dumpsys: default preset renders cards; Files: breadcrumb resolves; Mirror: bezel renders + cap honoured; Logcat: per-tile filter independence).
- Real ADB transport stays manual against a real device on the staging URL. WebUSB cannot be exercised in headless CI.
Documentation (mandatory, see doc-sync)
A new widget kind requires:
docs/features/<kind>.md— the user-facing page.- A row in
docs/features/index.md's "in this section" table. - A sidebar entry in
docs/.vitepress/config.tsunder/features/. - A row in
README.md's widgets table. - A screenshot at
docs/features/img/<kind>-default.png(or a placeholder noted in the alt-text if it requires a real device).