lfx-ui-builder/SKILL.md
Generate compliant Angular 20 frontend code — components, services, templates, drawers, pagination UI, and styling. Encodes signal patterns, component structure, PrimeNG wrapper strategy, and all frontend conventions. Only activates in Angular repos.
npx skillsauth add linuxfoundation/lfx-skills lfx-ui-builderInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
You are generating Angular 20 frontend code that must be PR-ready. This skill encodes all frontend conventions — use it for components, services, drawers, pagination, and templates.
Prerequisites: Backend endpoints must already exist. No mock data, no placeholder APIs.
Before generating any code, verify your args include:
| Required | If Missing | |----------|------------| | Specific task (what to build/modify) | Stop and ask — do not guess | | Absolute repo path | Stop and ask — never assume cwd | | Which module (committees, meetings, etc.) | Infer from context or ask | | Type definitions being used | Must be provided — cannot read from backend skill's output |
If invoked with a FIX: prefix, this is an error correction from the coordinator. Read the error, find the file, apply the targeted fix, and re-validate.
Before writing ANY code, you MUST:
/develop skill (if it exists at .claude/skills/develop/SKILL.md) — it defines repo-specific conventionsDo NOT generate code from memory alone. The codebase may have evolved since your training data.
This skill only applies to Angular repos. Verify before proceeding:
{ [ -f apps/lfx-one/angular.json ] || [ -f turbo.json ]; } || echo "ERROR: Not an Angular repo — /lfx-ui-builder does not apply here"
Before using the built-in patterns below, check if the current repo has its own development skill:
[ -f .claude/skills/develop/SKILL.md ] && echo "REPO_LOCAL_SKILL=true" || echo "REPO_LOCAL_SKILL=false"
If .claude/skills/develop/SKILL.md exists:
docs/architecture/frontend/*.md, rules in .claude/rules/)If no repo-local /develop skill exists: proceed with the built-in patterns below as the sole source of truth.
Every new .ts, .html, and .scss file MUST start with the appropriate license header:
TypeScript (.ts):
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
HTML (.html):
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->
SCSS (.scss):
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
When you finish, output a clear summary so the caller (usually /lfx-coordinator) and the user can see what happened:
═══════════════════════════════════════════
/lfx-ui-builder COMPLETE
═══════════════════════════════════════════
Files created:
- (none)
Files modified:
- member-form.component.ts — added bio FormControl, TextareaComponent import
- member-form.component.html — added bio textarea field
- member-card.component.html — added bio display section
Validation:
- Ran: yarn format
- Result: ✓ passed / ✗ failed with: <error>
Notes:
- Bio field uses lfx-textarea with 500 char max, 3 rows
- Follows linkedin_profile field pattern
Errors:
- (none)
═══════════════════════════════════════════
Always include the Validation section. Run yarn format after modifying files. Report the result.
| Category | Location |
| ------------------------- | ----------------------------------------------- |
| Route/page component | modules/<module>/<component-name>/ |
| Module-specific component | modules/<module>/components/<component-name>/ |
| Shared (cross-module) | shared/components/<component-name>/ |
| PrimeNG wrapper | shared/components/<component-name>/ |
Every component creates three files, each with the license header:
<name>.component.ts<name>.component.html<name>.component.scssFollow this exact order (from component-organization.md rule):
@Component({
selector: 'lfx-my-component',
standalone: true,
imports: [CommonModule, ButtonModule], // Direct imports, no barrel exports
templateUrl: './my-component.component.html',
styleUrl: './my-component.component.scss',
})
export class MyComponentComponent {
// 1. Private injections (readonly)
private readonly myService = inject(MyService);
private readonly router = inject(Router);
// 2. Public fields from inputs (readonly)
public readonly itemId = input.required<string>();
public readonly label = input<string>('Default');
// 3. Forms
public readonly form = new FormGroup({ ... });
// 4. Model signals (two-way binding)
public visible = model(false);
// 5. Simple WritableSignals (direct initialization)
public loading = signal(false);
public searchTerm = signal('');
public items = signal<Item[]>([]);
// 6. Complex computed/toSignal (via private init functions)
public filteredItems: Signal<Item[]> = this.initFilteredItems();
public dataFromServer: Signal<Data[]> = this.initDataFromServer();
// 7. Constructor (rarely needed with inject())
// 8. Public methods
public onSave(): void { ... }
// 9. Protected methods
protected onClose(): void { ... }
// 10. Private initializer functions (grouped together)
private initFilteredItems(): Signal<Item[]> {
return computed(() => {
const term = this.searchTerm().toLowerCase();
return this.items().filter(item => item.name.toLowerCase().includes(term));
});
}
private initDataFromServer(): Signal<Data[]> {
return toSignal(
toObservable(this.itemId).pipe(
filter(id => !!id),
switchMap(id => this.myService.getData(id)),
catchError(() => of([] as Data[]))
),
{ initialValue: [] as Data[] }
);
}
// 11. Private helper methods
private transformData(raw: RawData): Data { ... }
}
| Signal Type | Usage | Example |
| ------------------ | -------------------------- | --------------------------------------------- |
| signal() | Simple writable state | loading = signal(false) |
| input() | Parent -> child data | label = input<string>('Default') |
| input.required() | Required parent -> child | itemId = input.required<string>() |
| output() | Child -> parent events | saved = output<Item>() |
| computed() | Derived from other signals | total = computed(() => this.items().length) |
| model() | Two-way binding | visible = model(false) |
| toSignal() | Observable -> signal | data = toSignal(obs$, { initialValue: [] }) |
<!-- Use @if / @for (not *ngIf / *ngFor) -->
@if (loading()) {
<lfx-spinner />
} @else {
<div class="flex flex-col gap-4" data-testid="items-section">
@for (item of items(); track item.id) {
<div data-testid="item-card">{{ item.name }}</div>
}
</div>
}
flex + flex-col + gap-* — never space-y-*data-testid="[section]-[component]-[element]" on all key elementsAll PrimeNG components are wrapped for UI library independence:
@Component({
selector: 'lfx-my-wrapper',
standalone: true,
imports: [PrimeNgModule],
template: `
<p-component [options]="options()">
<ng-content />
</p-component>
`,
})
export class LfxMyWrapperComponent {
// CRITICAL: descendants: false prevents grabbing nested content
@ContentChild(SomeDirective, { descendants: false })
public template?: SomeDirective;
}
lfx-descendants: false on @ContentChild — this is critical<ng-content />) to pass through content| Anti-Pattern | Correct Pattern |
|-------------|-----------------|
| *ngIf="condition" | @if (condition()) { ... } |
| *ngFor="let item of items" | @for (item of items(); track item.id) { ... } |
| class="space-y-4" | class="flex flex-col gap-4" |
| {{ getLabel() }} (method in template) | {{ label \| myPipe }} (use pipe) |
| constructor(private svc: MyService) | private readonly svc = inject(MyService); |
| @Input() name: string | public readonly name = input<string>() |
| @Output() save = new EventEmitter() | public save = output<Item>() |
| Importing from barrel index.ts | Direct import from the component file |
| condition ? (a ? b : c) : d | Break into computed signals or @if blocks |
Location: apps/lfx-one/src/app/shared/services/<name>.service.ts
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { catchError, of, take } from 'rxjs';
import { MyItem } from '@lfx-one/shared/interfaces';
@Injectable({ providedIn: 'root' })
export class MyService {
private readonly http = inject(HttpClient);
// Shared state (when multiple components need the same data)
public items = signal<MyItem[]>([]);
// GET — catchError with sensible default
public getItems() {
return this.http.get<MyItem[]>('/api/items').pipe(catchError(() => of([] as MyItem[])));
}
// GET with params
public getItemsByProject(projectUid: string, pageSize?: number, pageToken?: string) {
let params = new HttpParams().set('parent', `project:${projectUid}`);
if (pageSize) params = params.set('page_size', pageSize.toString());
if (pageToken) params = params.set('page_token', pageToken);
return this.http
.get<PaginatedResponse<MyItem>>('/api/items', { params })
.pipe(catchError(() => of({ data: [], page_token: undefined } as PaginatedResponse<MyItem>)));
}
// POST/PUT/DELETE — take(1), let errors propagate
public createItem(payload: Partial<MyItem>) {
return this.http.post<MyItem>('/api/items', payload).pipe(take(1));
}
}
@Injectable({ providedIn: 'root' }) — always tree-shakeableinject(HttpClient) — never constructor-based DIcatchError(() => of(default)) for graceful degradationtake(1) and let errors propagate@lfx-one/shared/interfaces — never define locally/api/<resource>Drawers are slide-in detail panels.
public readonly visible = model<boolean>(false);
protected onClose(): void {
this.visible.set(false);
}
Load data only when the drawer opens:
private initDrawerData(): Signal<DrawerData> {
return toSignal(
toObservable(this.visible).pipe(
skip(1), // Skip initial false — prevents API call on init
switchMap(isVisible => {
if (!isVisible) {
this.drawerLoading.set(false);
return of(DEFAULT_VALUE);
}
this.drawerLoading.set(true);
return forkJoin({
monthly: this.service.getMonthly(accountId),
distribution: this.service.getDistribution(accountId),
}).pipe(
tap(() => this.drawerLoading.set(false)),
catchError(() => {
this.drawerLoading.set(false);
return of(DEFAULT_VALUE);
})
);
})
),
{ initialValue: DEFAULT_VALUE }
);
}
<p-drawer
[(visible)]="visible"
position="right"
[modal]="true"
[showCloseIcon]="false"
styleClass="xl:w-[45%] lg:w-[55%] md:w-[70%] sm:w-[90%] w-full"
data-testid="my-drawer">
<ng-template #header>
<div class="flex items-start justify-between gap-4 w-full">
<div class="flex flex-col gap-1 flex-1">
<h2 class="text-lg font-semibold text-gray-900">Title</h2>
</div>
<button type="button" (click)="onClose()" class="p-1 text-gray-400 hover:text-gray-600" aria-label="Close panel">
<i class="fa-light fa-xmark text-xl"></i>
</button>
</div>
</ng-template>
<div class="flex flex-col gap-6 pb-2">
@if (drawerLoading()) {
<div class="flex items-center justify-center py-12">
<i class="fa-light fa-spinner-third fa-spin text-2xl text-gray-400"></i>
</div>
} @else if (hasData()) {
<!-- Content sections -->
} @else {
<div class="text-center py-8 border border-slate-200 rounded-lg">
<p class="text-sm text-gray-500">No data available</p>
</div>
}
</div>
</p-drawer>
private pageToken = signal<string | undefined>(undefined);
public loadingMore = signal(false);
public hasMore = computed(() => !!this.pageToken());
private initItems(): Signal<Item[]> {
const firstPage$ = combineLatest([project$, filter$]).pipe(
switchMap(([project, filter]) => {
this.loading.set(true);
return this.service.getItems(project.uid, 50).pipe(
map((r): PageResult<Item> => ({ ...r, reset: true })),
finalize(() => this.loading.set(false))
);
})
);
const nextPage$ = this.loadMore$.pipe(
switchMap(pageToken => {
this.loadingMore.set(true);
return this.service.getItems(project.uid, 50, pageToken).pipe(
map((r): PageResult<Item> => ({ ...r, reset: false })),
finalize(() => this.loadingMore.set(false))
);
})
);
return toSignal(
merge(firstPage$, nextPage$).pipe(
tap(response => this.pageToken.set(response.page_token)),
scan((acc, response) => response.reset ? response.data : [...acc, ...response.data], [])
),
{ initialValue: [] }
);
}
@layer tailwind-base, primeng, tailwind-utilities@lfx-one/shared/constants (lfxColors)fa-light default, fa-solid for emphasisflex + flex-col + gap-* — never space-y-*sm:, md:, lg:, xl:).ts, .html, .scss) with license headerslfx-@if/@for (not *ngIf/*ngFor)flex + gap-* (not space-y-*)data-testid attributes on key elements@Injectable({ providedIn: 'root' })inject(HttpClient) (not constructor DI)catchError with sensible defaulttake(1)@lfx-one/shared/interfaces/api/...)This skill DOES:
yarn format after changesThis skill does NOT:
/lfx-backend-builder)packages/shared/ (use /lfx-backend-builder)/lfx-product-architect)app.routes.ts, apps/lfx-one/angular.json) — flag for code ownerdevelopment
LFX cross-repo topology and ownership router. Use when the task spans more than one LFX repo, asks "which repo owns X", "where does Y live", "what repos does this touch", "what consumes Z", or needs a peer-repo file path from inside a single repo. Loads per-repo configs when invoked from the LFX workspace root with a full task prompt; gives targeted cross-repo guidance when invoked from inside a single repo. Also answers LFX glossary and topology questions, and performs read-only discovery when the user asks whether a contract, API, event, field, workflow, or repo capability exists. Do not fire for single-repo implementation tasks where the active repo's own CLAUDE.md already governs (those belong to the repo's local skills). Do not fire for V2 platform composition, service classes, or cross-service handoffs (use `/lfx-skills:lfx-platform-architecture`), ITX wrapper plumbing (`/lfx-skills:lfx-itx-integration`), or Intercom app/Fin workflows (`/lfx-skills:lfx-intercom`).
tools
Create a new ticket in the LFXV2 Jira project (linuxfoundation.atlassian.net). Guides the user through picking an issue type (Bug, Story, Task, Epic), writing a concise summary, and capturing the requirement, feature, or bug context, collecting reproduction steps for bugs. Optionally attaches a parent epic, labels, or priority if the user provides them. Submits the ticket via Atlassian MCP and returns the URL. Use this skill any time someone asks to "create a Jira ticket", "open an LFXV2 ticket", "file a bug", "log a story", "write up a feature request", "draft a ticket", or any variation of submitting work into LFXV2.
testing
Combine multiple feature branches across repos into worktrees for end-to-end journey testing. Create, refresh, and teardown integration environments that merge branches from multiple repos.
devops
Guide users through requesting Snowflake access at the Linux Foundation. Handles two request types: (1) individual user access, adding or modifying an entry in users.tf in the lfx-snowflake-terraform repo, and (2) service account creation, adding an entry in service_accounts.tf. For each, the skill collects the necessary details, generates the exact Terraform HCL block to add, explains where to place it, and guides the user through the PR process. Use this skill any time someone asks about Snowflake access, permissions, user provisioning, service accounts, or making changes to the lfx-snowflake-terraform repo, including phrases like "get access to Snowflake", "add me to Snowflake", "need a service account", "request Snowflake permissions", "I need to query Snowflake", or "how do I get Snowflake access".