Guidelines for building UI widgets using the MVP architecture in DevTools. Covers Widget lifecycle, lit-html views, and state management.
55
61%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./.agents/skills/ui-widgets/SKILL.mdAdhere strictly to the Model-View-Presenter (MVP) architecture.
front_end/models/): Handle business logic and application state. MUST NOT have knowledge of the UI.Widget): Orchestrate data flow, manage UI-specific state, and handle interactions.function): Purely presentational. Stateless functions that receive data/callbacks and render using lit-html.Refactoring Note: When refactoring legacy widgets to this framework, the widget's class name MUST NOT be changed. If you're refactoring a UI piece that uses shadow root (e.g. custom HTMLElements) to a Widget, make sure to update its styles to wrap the rules with a @scope to (devtools-widget > *) rule.
Widget) RulesUI.Widget class (e.g., UI.Widget.Widget). Note that UI.Widget.Widget is not an HTMLElement and must be appended via .show() or UI.Widget.widget#view field.super(). If taking element?: HTMLElement, pass it to super(element). super(true) is forbidden.Universe SHOULD be requested via static INJECT: static override readonly INJECT = [...] as const; and received as constructor parameter #2 ([dep1, dep2]: UI.Widget.WidgetDependencies<typeof MyWidget>).this.registerCSSFiles() is forbidden.override wasShown(): void by calling this.requestUpdate().#counter).this.requestUpdate(). performUpdate() MUST NOT be called directly.function) RulesDEFAULT_VIEW, in the same file as the Presenter.(input: ViewInput, output: ViewOutput, target: HTMLElement).
input: Data and event handlers from the Presenter.output: Callbacks for imperative actions (e.g., focus) provided to the Presenter. If unused, the output parameter should be typed as undefined in the view's signature, and undefined should be passed from the presenter.target: The HTML element to render into.input parameter MUST NOT be destructured. Always use input.propertyName.lit-html template into target.Complex views can be broken down using helper functions defined inside DEFAULT_VIEW to access input and output.
// clang-format off
const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => {
const renderHeader = (): Lit.LitTemplate => {
return html`<h1>${input.title}</h1>`;
};
// ...
render(html`
<div class="child-widget">
${renderHeader()}
</div>
`, target);
};
// clang-format onUI.Widget.wigetrequestUpdate() on the child when the parent re-renders.When migrating imperative components (extending UI.VBox, UI.Panel, or HTMLElement) to the MVP architecture:
wasShown, imperative methods). This logic moves to the DEFAULT_VIEW.UI.Widget.Widget.UI.Panel.Panel or UI.Dialog.Dialog (to retain specific functionality), you cannot use requestUpdate(). Instead, call this.performUpdate() directly on state changes.new MyComponent() instantiations with declarative widget(MyComponent, {params}) in parent templates.@scope block to prevent style leaks:
@scope to (devtools-widget > *) {
...
}DevTools widgets typically render into Light DOM (the default for UI.Widget). To ensure styles are scoped to the component and do not leak into child widgets, wrap your CSS in an @scope block:
/* myWidget.css */
@scope to (devtools-widget > *) {
.my-class { ... }
}Then, include the styles in your DEFAULT_VIEW:
import myWidgetStyles from './myWidget.css.js';
// ...
render(html`
<style>${myWidgetStyles}</style>
<div class="my-widget">...</div>
`, target);Note: The {host: input} option in render() is generally not required unless you are using specific Shadow DOM patterns that necessitate it. The @scope strategy is the preferred method for style isolation in DevTools widgets.
Lit.Directives.ref to obtain a reference to a raw HTMLElement if needed for imperative APIs (e.g., splitWidget.installResizer(element)).UI.Widget.widgetRef to obtain the class instance of a child widget if you need to call methods on it directly (though declarative data flow is preferred).DEFAULT_VIEW is typically a module-level function. Ensure all dependencies (enums, constants, other components) are imported at the top of the file so they are available in the function scope.Universe) Dependencies: To access dependencies managed by Universe (e.g. SDK.TargetManager.TargetManager, Common.Settings.Settings), declare a static INJECT array on the Widget subclass:
export class MyWidget extends UI.Widget.Widget {
static override readonly INJECT = [SDK.TargetManager.TargetManager, Common.Settings.Settings] as const;
#targetManager: SDK.TargetManager.TargetManager;
#settings: Common.Settings.Settings;
#view: View;
constructor(
element?: HTMLElement,
[targetManager, settings]: UI.Widget.WidgetDependencies<typeof MyWidget> = [],
view: View = DEFAULT_VIEW,
) {
super(element);
this.#targetManager = targetManager;
this.#settings = settings;
this.#view = view;
}
}instantiateWidget automatically resolves dependencies from Universe and passes them as constructor parameter #2. In unit tests using renderElementIntoDOM, TestUniverse handles injection automatically (override via setTestUniverseForWidgets).Example: front_end/panels/my_example/MyExampleWidget.ts.
/* front_end/panels/my_example/myExampleWidget.css */
@scope to (devtools-widget > *) {
p {
color: blue;
}
}// front_end/panels/my_example/MyExampleWidget.ts
import * as Lit from '../../ui/lit/lit.js';
import * as UI from '../../ui/legacy/legacy.js';
import myExampleWidgetStyles from './myExampleWidget.css.js';
const {html, render, ref} = Lit;
// 1. Define Input data shape. (Assuming Child Widget configuration for completeness)
export interface ViewInput {
title: string;
counter: number;
onButtonClick: () => void;
}
// 2. Define Output imperative API shape.
export interface ViewOutput {
focusButton?: () => void;
}
// 3. Define the View function.
// clang-format off
const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => {
render(html`
<style>${myExampleWidgetStyles}</style>
<div class="child-widget">
<h3>${input.title}</h3>
<p>Counter: ${input.counter}</p>
<button @click=${input.onButtonClick} ${ref(el => {
if (el) {
output.focusButton = () => el.focus();
}
})}>Increment</button>
</div>
`, target);
};
// clang-format on
// 4. Type alias for the view.
type View = typeof DEFAULT_VIEW;
// 5. Define the Presenter.
export class MyExampleWidget extends UI.Widget.Widget {
#counter: number = 0;
// Public field REQUIRED to receive data from a parent.
title: string = 'Default Title';
#view: View;
#viewOutput: ViewOutput = {};
constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
super(element);
this.#view = view;
}
override wasShown(): void {
super.wasShown();
// Initial render trigger.
this.requestUpdate();
}
focus(): void {
this.#viewOutput.focusButton?.();
}
#handleButtonClick = (): void => {
this.#counter++;
this.requestUpdate();
}
override performUpdate(): void {
const viewInput = {
title: this.title,
counter: this.#counter,
onButtonClick: this.#handleButtonClick,
};
this.#view(viewInput, this.#viewOutput, this.contentElement);
}
}Example: front_end/panels/parent/ParentWidget.ts. Demonstrates passing state via widgetConfig and a view with no output.
// front_end/panels/parent/ParentWidget.ts
import * as Lit from '../../ui/lit/lit.js';
import * as UI from '../../ui/legacy/legacy.js';
import {MyExampleWidget} from '../my_example/MyExampleWidget.ts';
import parentWidgetStyles from './parentWidgetStyles.css';
const {html, render} = Lit;
const {widgetConfig} = UI.Widget;
interface ViewInput {
title: string;
onTitleChange: () => void;
}
// Parent View
// clang-format off
const DEFAULT_VIEW = (input: ViewInput, output: undefined, target: HTMLElement): void => {
render(html`
<style>${parentWidgetStyles}</style>
<div class="parent-container">
<h1>Parent Widget</h1>
<button @click=${input.onTitleChange}>Change Child Title</button>
<!-- Pass properties to the child widget. -->
${widget(MyExampleWidget, {title: input.title})}
</div>
`, target);
};
// clang-format on
type View = typeof DEFAULT_VIEW;
// Parent Presenter
export class ParentWidget extends UI.Widget.Widget {
#childTitle: string = 'Initial Title';
#view: View;
constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
super(element);
this.#view = view;
}
override wasShown(): void {
super.wasShown();
this.requestUpdate();
}
#handleChangeTitleClick = () => {
this.#childTitle = `Title set at ${new Date().toLocaleTimeString()}`;
this.requestUpdate();
}
override performUpdate(): void {
this.#view({
title: this.#childTitle,
onTitleChange: this.#handleChangeTitleClick,
}, undefined, this.contentElement);
}
}Testing focuses on the Presenter's logic. The View MUST be replaced by a stub using createViewFunctionStub.
describeWithEnvironment: Sets up the testing environment.createViewFunctionStub: Stubs the view to capture input data.renderElementIntoDOM: Attaches the widget to the DOM to trigger lifecycle methods (like wasShown).view.nextInput: A promise that resolves with the next input when the widget re-renders.view.input.onButtonClick()), do not interact with the DOM.// front_end/panels/my_example/MyExampleWidget.test.ts
import {
renderElementIntoDOM,
} from '../../testing/DOMHelpers.js';
import {describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import {createViewFunctionStub} from '../../testing/ViewFunctionHelpers.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as MyExampleWidget from './MyExampleWidget.js';
const {MyExampleWidget} = MyExampleWidget;
describeWithEnvironment('MyExampleWidget', () => {
// Helper to set up the widget with a stubbed view.
async function createWidget() {
// 1. Stub the view function.
const view = createViewFunctionStub(MyExampleWidget);
// 2. Instantiate the widget, injecting the stub.
const widget = new MyExampleWidget(undefined, view);
// 3. Render the widget into the DOM (triggers `wasShown`).
widget.markAsRoot();
renderElementIntoDOM(widget);
// 4. Wait for the initial render.
await view.nextInput;
return {view, widget};
}
it('renders with initial state', async () => {
const {view} = await createWidget();
assert.strictEqual(view.input.counter, 0);
});
it('increments counter on button click', async () => {
const {view} = await createWidget();
// Simulate the click by invoking the callback.
view.input.onButtonClick();
// Wait for the re-render.
const finalInput = await view.nextInput;
assert.strictEqual(finalInput.counter, 1);
});
it('updates its title when a new one is passed in', async () => {
const {view, widget} = await createWidget();
widget.title = 'New Test Title';
widget.requestUpdate();
const finalInput = await view.nextInput;
assert.strictEqual(finalInput.title, 'New Test Title');
});
});This document is a living guide. If you find that the instructions are incomplete, lead to errors, or could be improved, please suggest updates. Your goal is to make this guide as helpful as possible for both human developers and your future AI counterparts.
184a74d
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.