Specialized assistant for implementing and maintaining web components in the DWP Hours Tracker frontend
Specialized assistant for implementing and maintaining web components in the DWP Hours Tracker frontend. Provides guidance on creating reusable, encapsulated UI components using modern web standards, following MDN Web Components best practices.
Activate when users need to:
Follow this structured approach when implementing web components:
Component Analysis: Assess the component's purpose, props, state, and integration needs
CRITICAL: Static Imports Only - Never use await import() or dynamic imports. All imports must be static at the top level. The build system uses esbuild to create a single app.js bundle loaded by test.html pages.
CRITICAL: Declarative Markup Priority - Always prefer declarative template strings returned from render() over imperative DOM construction (manual innerHTML assignment, createElement, appendChild, IIFEs inside template literals). The render() method should read like a description of what the component displays. Extract complex conditional sections into small helper methods that return partial template strings, keeping render() itself a clear, top-level declaration of the component's structure.
CRITICAL: Named Slots Over Component Embedding - Never create child web components inside a parent's shadow DOM template string. Instead, use <slot name="..."> in the parent's template and let the consumer compose children in light DOM. This keeps components loosely coupled, independently testable, and composable. The parent declares where children go; the consumer decides which children to provide.
CSS Organization: Create a separate css.ts file with exported styles template string
Design Tokens: Use CSS custom properties from tokens.css for consistent theming
Responsive Scaling: Use global --scale-factor variable for proportional scaling at small screens
Base Class Selection: Extend BaseComponent for memory-safe, consistent components
Custom Element Definition: Create class extending BaseComponent with proper naming conventions
Shadow DOM Setup: Automatic shadow root creation via BaseComponent
Lifecycle Methods: Override connectedCallback, disconnectedCallback as needed (BaseComponent handles cleanup)
Template & Styling: Define component template and styles following MDN best practices
CRITICAL: Strongly-Typed Getter/Setter Properties with Attribute Backing - Use ES property getters/setters backed by the attributes collection for primitive values only (string, number, boolean). The setter writes to setAttribute(), and attributeChangedCallback is the single point that calls requestUpdate() — preventing double renders. The getter reads from getAttribute() and parses to the correct type. Guard attributeChangedCallback with oldValue === newValue to prevent cycles — this works reliably because primitives serialize to deterministic strings.
Complex values (arrays, objects) must NOT use attributes. Use private fields with get/set accessors that call requestUpdate() directly. Reasons:
oldValue === newValue string comparison fails for JSON — semantically identical objects can produce different serializations (key ordering, whitespace)string | null)// ── Primitives: attribute-backed ──
// oldValue === newValue works because primitives serialize deterministically.
static get observedAttributes() { return ["month", "year", "readonly"]; }
get month(): number { return parseInt(this.getAttribute("month") || "1", 10); }
set month(value: number) { this.setAttribute("month", value.toString()); }
get readonly(): boolean { return this.getAttribute("readonly") === "true"; }
set readonly(value: boolean) { this.setAttribute("readonly", value.toString()); }
// Single render trigger for all attribute changes — no cycles
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (oldValue === newValue) return; // ✅ Safe — primitives only
this.requestUpdate();
}
// ── Complex values: private field, bypasses attributes entirely ──
// No JSON serialization, no attribute equality issues.
private _ptoEntries: PTOEntry[] = [];
get ptoEntries(): PTOEntry[] { return this._ptoEntries; }
set ptoEntries(value: PTOEntry[]) {
this._ptoEntries = value;
this.requestUpdate(); // ✅ Direct — no attributeChangedCallback involved
}
CRITICAL: View-Model Rendering with Focus Preservation - Component state fields form a view-model. render() is a pure function of this view-model. After each render() / requestUpdate() cycle, focus and input element state (cursor position, selection) must be restored. BaseComponent.renderTemplate() handles focus restore for elements with stable id attributes automatically. For elements identified by data-* attributes (e.g., calendar days), override update() to restore focus from the view-model after super.update(). Without focus preservation, re-rendering completely breaks UX (lost cursor position, dropped keyboard navigation).
Event Handling: Use event delegation via handleDelegatedClick/handleDelegatedSubmit methods
Data Flow Architecture: Use event-driven data flow - components dispatch events for data requests, parent handles API calls and data injection via methods like setPtoData()
Memory Management: BaseComponent automatically handles event listener cleanup
Unit Testing: Create Vitest tests with happy-dom using seedData for mocking
Integration Testing: Test component in the DWP Hours Tracker context with Playwright E2E tests
Documentation: Update component usage documentation
When creating web components, follow this testing structure:
client/components/[component-name]/
├── index.ts # Component implementation
├── test.html # Manual testing page
├── test.ts # Automated test playground
└── [component-name].test.ts # Vitest unit tests (in tests/components/)
tests/components/
└── [component-name].test.ts # Vitest unit tests with happy-dom
Create comprehensive unit tests using Vitest with happy-dom environment:
shared/seedData.ts to provide realistic test data without network callssetPtoData()) to inject mock datarender() directly: render() is a pure template method that returns a string — calling it directly is a no-op for BaseComponent subclasses. Use requestUpdate() to trigger re-renders, or use data injection methods (like setData()) which call requestUpdate() internallyExample Vitest test structure:
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import { ComponentName } from "../../client/components/[component-name]/index.js";
import { seedDataType } from "../../shared/seedData.js";
describe("ComponentName Component", () => {
it("should render correctly with mock data", () => {
const component = new ComponentName();
component.setData(mockDataFromSeed);
// Assert DOM structure and content
});
});
Mocking with Seed Data: Always use shared/seedData.ts for realistic test data. Transform seed data into component-specific formats using data injection methods rather than simulating API calls. See testing-strategy/SKILL.md for detailed guidelines on seed data usage - only "test.ts" files should import seedData.ts directly.
Create end-to-end tests using Playwright for integration testing:
Since components use event-driven architecture and don't make direct API calls, Playwright tests focus on:
Example Playwright test:
test("component displays correctly in admin panel", async ({ page }) => {
// Navigate to test page with real data
await page.goto("/components/admin-panel/test.html");
// Take screenshots of different states
await expect(page.locator("component-name")).toHaveScreenshot();
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Component Name Test</title>
<link rel="stylesheet" href="../../styles.css" />
</head>
<body>
<h1>Component Name Test</h1>
<div
id="test-output"
style="display: block; padding: 10px; border: 1px solid var(--color-border); margin: 10px 0; background: var(--color-surface); color: var(--color-text);"
></div>
<component-name id="component-id"></component-name>
<debug-console></debug-console>
<script type="module">
import { componentPlayground } from "/app.js";
componentPlayground();
</script>
</body>
</html>
Design Constraints:
await import() is strictly forbidden. All imports must be static at the top level of files. The project uses esbuild to create a single app.js artifact loaded by test.html pages. Dynamic imports break the build system and will cause runtime errors./app.js to ensure all components are loaded and registeredplayground function from the corresponding test.ts file when complex test scenarios are needed<debug-console></debug-console> to the HTML for debugging output during manual testingNote on Playground Functions: The componentPlayground function imported from /app.js is an alias defined in client/components/test.ts. Each component's test.ts file exports a playground function that is imported and re-exported with a component-specific name (e.g., ptoCalendar, employeeList) in the main test.ts file. This allows the test.html to call the appropriate playground function for interactive testing.
import { querySingle } from "../test-utils.js";
export function playground() {
console.log("Starting component playground test...");
const component = querySingle<ComponentType>("component-name");
// Test component functionality
component.addEventListener("custom-event", (e: CustomEvent) => {
console.log("Event received:", e.detail);
querySingle("#test-output").textContent =
`Event: ${JSON.stringify(e.detail)}`;
});
// Additional test scenarios...
console.log("Component playground test initialized");
}
client/components/index.tsclient/components/test.tstests/components/[component-name].test.tse2e/component-[name].spec.tsComponents should not make direct API calls. Instead, use event-driven architecture:
// In component: dispatch event for data request
this.dispatchEvent(
new CustomEvent("pto-data-request", {
bubbles: true,
detail: { employeeId: this.employeeId },
}),
);
// In parent app: listen for event and handle data fetching
addEventListener("pto-data-request", (e: CustomEvent) => {
this.handlePtoDataRequest(e.detail);
});
// Parent injects data via component method
component.setPtoData(fetchedData);
This pattern maintains separation of concerns and makes components more testable.
get/set property accessors. Primitives are backed by attributes (getAttribute/setAttribute); complex values use private fields. Both provide type-safe APIs to callers (e.g., calendar.month = 3, calendar.ptoEntries = [...])Components must never embed other web components by tag name inside their shadow DOM template. Instead, declare named <slot> elements and let consumers compose children in light DOM:
// CORRECT: Parent declares a slot
protected render(): string {
return `
<style>/* ... */</style>
<div class="card">
<h4>Monthly Accrual</h4>
<div class="grid"><!-- grid rows --></div>
<slot name="calendar"></slot> <!-- ✅ Consumer provides the calendar -->
</div>
`;
}
// Consumer composes in light DOM:
// <pto-accrual-card>
// <pto-calendar slot="calendar" month="3" year="2026"></pto-calendar>
// </pto-accrual-card>
// WRONG: Embedding child component in shadow DOM template
protected render(): string {
return `
<div class="card">
<pto-calendar month="3" year="2026"></pto-calendar> <!-- ❌ Tight coupling -->
</div>
`;
}
This pattern keeps components independently testable and avoids tight coupling between parent and child shadow DOMs.
BaseComponent follows the Lit reactive update cycle. Understanding this lifecycle is mandatory — violating it causes subtle bugs (silent no-ops, duplicate listeners, stale DOM).
Property change or requestUpdate()
│
▼
update() ← Called by the framework. Do NOT call directly.
│
├─ render() ← Pure template function. Returns a string. No side effects.
│
▼
renderTemplate() ← Applies the string to shadowRoot.innerHTML.
│
├─ cleanupEventListeners()
├─ shadowRoot.innerHTML = template
└─ setupEventDelegation()
render() Contractrender() is a pure template method. It conforms to the Lit specification:
| Rule | Detail |
|---|---|
| Returns | An HTML template string |
| Side effects | None. Must not modify DOM, dispatch events, or call external APIs |
| Called by | update() only — never by application code, test code, or other components |
| Calling it directly | Returns the string but does NOT apply it to the DOM — a silent no-op |
| To trigger a re-render | Call requestUpdate() — this is the ONLY correct way |
// CORRECT: render() returns a template string, requestUpdate() triggers the cycle
protected render(): string {
return `<div>${this._data}</div>`;
}
setData(data: string) {
this._data = data;
this.requestUpdate(); // ✅ Triggers: update() → render() → renderTemplate()
}
// WRONG: Calling render() directly — return value is discarded, DOM unchanged
component.render(); // ❌ No-op
el.render(); // ❌ No-op in evaluate() blocks
form.render(); // ❌ No-op
this.render(); // ❌ No-op (inside component methods — use requestUpdate())
Following Lit conventions:
| Method | Purpose | Call super? | Override? |
|---|---|---|---|
constructor() | Initialize state, attach shadow root | Yes (automatic via BaseComponent) | Rarely |
connectedCallback() | Start tasks, set up external listeners | Yes | When needed |
disconnectedCallback() | Clean up external listeners | Yes | When needed |
render() | Return template string | No | Always (abstract) |
requestUpdate() | Schedule a re-render | No (just call it) | Never |
update() | Orchestrate render cycle | — | Never |
setupEventDelegation() | Register event listeners on shadowRoot | Yes | When adding custom event listeners |
handleDelegatedClick() | Handle click events via delegation | — | When needed |
handleDelegatedSubmit() | Handle form submit events via delegation | — | When needed |
setupEventDelegation() ContractListeners on shadowRoot survive innerHTML replacement (they're on the root node, not child elements). Therefore:
BaseComponent guards with isEventDelegationSetup to prevent duplicates from its own listeners_customEventsSetup) to prevent listener accumulation across re-rendersrequestUpdate() adds duplicate listeners → handler called N times per eventprivate _customEventsSetup = false;
protected setupEventDelegation() {
super.setupEventDelegation();
if (this._customEventsSetup) return; // ← REQUIRED guard
this._customEventsSetup = true;
this.shadowRoot.addEventListener("my-event", (e) => {
e.stopPropagation();
this.handleCustomEvent(e as CustomEvent);
});
}
See the Happy DOM skill for the confirmed listener accumulation bug this pattern prevents.
For consistency and memory leak prevention, all web components should extend the BaseComponent class located in client/components/base-component.ts.
requestUpdate() method for triggering re-renders (the only correct way to update the DOM)disconnectedCallbackimport { BaseComponent } from "../base-component.js";
import { styles } from "./css.js";
export class MyComponent extends BaseComponent {
private _data: MyData[] = [];
// render() is a PURE TEMPLATE METHOD — returns string, no side effects.
// Prefer flat, declarative markup. Extract conditionals into helper methods
// that return partial template strings rather than embedding IIFEs or deep
// ternary chains.
protected render(): string {
return `
${styles}
<div class="my-component">
${this.renderItems()}
</div>
`;
}
// Helper: returns a declarative template fragment
private renderItems(): string {
if (!this._data.length) return `<div class="empty">No items</div>`;
return this._data.map((item) => `<div>${item.name}</div>`).join("");
}
protected handleDelegatedClick(e: Event): void {
const target = e.target as HTMLElement;
if (target.matches(".action-btn")) {
this.handleAction();
}
}
// Complex data: private field with typed accessor
private _data: MyData[] = [];
get data(): MyData[] {
return this._data;
}
set data(value: MyData[]) {
this._data = value;
this.requestUpdate(); // ✅ Triggers the full update cycle
}
}
The project provides shared CSS extension libraries in client/css-extensions/. Each extension delivers a constructable stylesheet singleton and an adopt*() helper that safely adds it to a shadow root's adoptedStyleSheets. Import from the facade for convenience:
import { adoptAnimations, adoptToolbar } from "../../css-extensions/index.js";
Or import from individual sub-modules for tree-shaking:
import { adoptAnimations } from "../../css-extensions/animations/index.js";
import { adoptToolbar } from "../../css-extensions/toolbar/index.js";
| Extension | Adopt Helper | CSS Classes / Features |
|---|---|---|
| Animations | adoptAnimations(root) | .anim-fade-in, .anim-slide-in-right, .anim-slide-out-left, .anim-pop, etc. Plus JS helpers: animateSlide(), animateCarousel() |
| Toolbar | adoptToolbar(root) | .toolbar — flex layout with justify-content: space-around for evenly distributed action buttons |
Adopt extensions in connectedCallback() so the constructable stylesheet is added to the shadow root once:
import { adoptAnimations, adoptToolbar } from "../../css-extensions/index.js";
export class MyComponent extends BaseComponent {
connectedCallback() {
super.connectedCallback();
adoptAnimations(this.shadowRoot);
adoptToolbar(this.shadowRoot);
}
protected render(): string {
return `
<div class="toolbar">
<button>Action A</button>
<button>Action B</button>
</div>
`;
}
}
client/css-extensions/<name>/<name>.ts with the CSS source string (use var() tokens, no hardcoded values)index.ts with a constructable stylesheet singleton and adopt<Name>() helperclient/css-extensions/index.tsAll component styles should be defined in a separate css.ts file that exports a styles template string. This pattern provides:
// css.ts - Component-specific styles
export const styles = `
.component-root {
padding: var(--space-md);
border-radius: var(--border-radius);
}
.component-header {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
}
`;
// index.ts - Component implementation
import { styles } from "./css.js";
export class MyComponent extends BaseComponent {
protected render(): string {
return `
${styles}
<div class="component-root">
<h2 class="component-header">Title</h2>
</div>
`;
}
}
Use CSS custom properties from client/tokens.css for consistent theming:
--color-primary, --color-text, --color-surface, etc.--font-size-sm, --font-weight-medium, etc.--space-sm, --space-md, etc.--border-width, --border-radius, etc.Components can use the global --scale-factor CSS variable for proportional scaling at small screen sizes:
app.ts based on viewport widthscale(calc(100vw / 320)) provides continuous scaling from 320px baseline// In css.ts
export const styles = `
@media (max-width: 320px) {
.component-container {
transform: scale(var(--scale-factor));
transform-origin: top left;
}
}
`;
This ensures all components scale consistently while maintaining proportions at super small resolutions.
Problem: Components that replace innerHTML without cleaning up event listeners cause memory leaks.
Solution: BaseComponent uses event delegation and automatic cleanup:
cleanupEventListeners() called before re-rendersdisconnectedCallback() ensures cleanup on removalMany existing components extend HTMLElement directly and define render() as a method that sets innerHTML. When migrating:
extends HTMLElement to extends BaseComponentattachShadow() callsrender() to return a string instead of setting this.shadowRoot.innerHTML directlythis.render() calls with this.requestUpdate() — this is the most critical stepget/set accessors backed by getAttribute()/setAttribute(), with attributeChangedCallback as the single requestUpdate() trigger (guarded by oldValue === newValue). For complex properties (arrays, objects), use private fields with get/set accessors that call requestUpdate() directly. Remove any setFoo() methods that duplicate this pattern.handleDelegatedClick() and handleDelegatedSubmit() for events_customEventsSetup guard if overriding setupEventDelegation()BaseComponent.renderTemplate() handles id-based focus restore. For data-* based focus (e.g., grids, lists), override update() to restore focus from view-model state after super.update()Unmigrated components (extending HTMLElement with imperative render()): pto-calendar, pto-request-queue, data-table, confirmation-dialog, prior-year-review, pto-entry-form, report-generator. These call this.render() directly, which works because their render() imperatively sets innerHTML. They will break if migrated to BaseComponent without replacing this.render() → this.requestUpdate(). They also typically use observedAttributes / attributeChangedCallback which should be replaced with setter methods during migration.
Migrated PTO cards (now extending BaseComponent with declarative render()): pto-employee-info-card, pto-summary-card, pto-pto-card, pto-bereavement-card, pto-sick-card, pto-jury-duty-card, pto-accrual-card. Shared CSS lives in utils/pto-card-css.ts (CARD_CSS), shared template helpers in utils/pto-card-helpers.ts (renderCardShell, renderRow, renderBucketBody, etc.). The old PtoSectionCard and SimplePtoBucketCard base classes in utils/pto-card-base.ts are deprecated.
Page components that wrap child web components should act as data controllers: they define the structural template (which child elements exist) in render(), and push data into those children via property setters and method calls — never by re-rendering the page's own shadow DOM.
requestUpdate() on a page should only be called when the page's own template structure changes (e.g., initial render in onRouteEnter(), or adding/removing child elements). It must not be called after data mutations (approve, reject, save) that only change the data flowing into existing children.
After data mutations, fetch fresh data and push it directly to child components:
// CORRECT: targeted injection — child state preserved
const queue = this.shadowRoot.querySelector(
"pto-request-queue",
) as PtoRequestQueue;
queue.requests = freshRequests; // queue's own setter triggers its internal re-render
// WRONG: page re-render — destroys all child state
this.requestUpdate(); // calendar expanded state, scroll position, animations lost
Child components that need data should dispatch custom events (bubbles + composed); the parent page listens, fetches, and injects results via methods:
// Child dispatches request
this.dispatchEvent(
new CustomEvent("calendar-data-request", {
bubbles: true,
composed: true,
detail: { employeeId, month },
}),
);
// Page handles in setupEventDelegation()
this.shadowRoot.addEventListener("calendar-data-request", (evt) => {
const { employeeId, month } = (evt as CustomEvent).detail;
// fetch data, then inject:
queue.setCalendarEntries(employeeId, month, normalized);
});
AdminMonthlyReviewPage — listens for admin-monthly-review-request and calendar-month-data-request, fetches data, injects via setEmployeeData() / setPtoEntries() / setMonthPtoEntries()AdminPtoRequestsPage — listens for calendar-data-request, fetches scoped PTO entries, injects via queue.setCalendarEntries(); after approve/reject, sets queue.requests directly without calling this.requestUpdate()Common queries that should trigger this skill:
css.ts files with exported styles template strings for maintainable, type-safe stylingtokens.css for consistent theming across all components--scale-factor variable for proportional scaling at small screen sizesrender() is a pure template method; requestUpdate() is the only way to trigger DOM updatesrender() should read as a flat, top-level description of what the component displays. Complex sections should be extracted into helper methods returning template fragments — never use IIFEs, deeply nested ternaries, or manual innerHTML assignment in component logic.<slot> elements so consumers compose children in light DOM. This ensures loose coupling and independent testability.BaseComponent directly over deep inheritance chains. Share behavior via exported utility functions and CSS constants in TypeScript files rather than intermediate base classes.task-implementation-assistant for admin panel tasks, code-review-qa for component quality checks</content>
<parameter name="filePath">/home/ca0v/code/ca0v/dwp-hours/.github/skills/web-components-assistant/SKILL.md