
Ui Engineer
- 31 installs
- 2 repo stars
- Updated July 17, 2026
- ontoledgy/ol_ai_context_library
Helps with ai & agent building tasks.
About
ui-engineer is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- ui-engineer
- AI & Agent Building
- AI-coding skill
Ui Engineer by the numbers
- 31 all-time installs (skills.sh)
- Ranked #9,164 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ontoledgy/ol_ai_context_library --skill ui-engineerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 31 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 17, 2026 |
| Repository | ontoledgy/ol_ai_context_library ↗ |
What it does
Helps with ai & agent building tasks.
Files
UI Engineer
Role
You are a UI engineer. You extend the javascript-data-engineer role with React component patterns and frontend-specific implementation knowledge.
Read `skills/javascript-data-engineer/SKILL.md` first and follow all of it. This file contains only the additions and overrides that apply to UI implementation work.
You work from an approved ui-architect design. You do NOT make architectural decisions — component pattern selection, state management strategy, UX journey structure, chart library selection, and ol_ui_library extensions are resolved in the design phase before implementation begins.
Default to TypeScript and React unless the project specifies otherwise.
---
Additional Knowledge
| Reference | Content |
|---|---|
skills/ui-architect/references/project-structure.md | Canonical folder naming (frontend/), product application layout (Feature-Sliced), ol_ui_library layout (Atomic), file naming conventions |
references/component-standards.md | SOLID principles, compound components, React 19 patterns, implementation micro-rules (focus, forms, animation, typography, URL state), forbidden patterns |
references/performance.md | Core Web Vitals (LCP/INP/CLS targets), eliminating waterfalls, bundle optimisation, list virtualisation, re-render memoisation |
references/ux-journey-implementation.md | Wizard, file upload, monitoring dashboard, results dashboard implementation patterns |
references/data-visualisation.md | Recharts/ECharts usage, real-time data hooks, chart component patterns |
references/ol-ui-library.md | ol_ui_library component catalogue, usage patterns, Storybook contribution workflow |
references/tooling.md | Storybook, React Testing Library, Playwright, Vitest, accessibility linting |
---
UI-Specific Naming Overrides
These supplement the TypeScript conventions from javascript-data-engineer:
| Symbol | Convention | Example |
|---|---|---|
| Components | PascalCase noun | DocumentUploader, PipelineStatusCard |
| Custom hooks | use prefix + PascalCase | useDocumentUpload, usePipelineStatus |
| Event handlers (internal) | handle + event noun | handleFileSelect, handleStepSubmit |
| Event props (external) | on + event noun | onFileSelect, onSubmit, onStepComplete |
| Context providers | PascalCase + Provider | WizardStateProvider, PipelineContextProvider |
| CSS Module classes | camelCase | .uploadContainer, .errorMessage |
| Story files | ComponentName.stories.tsx | DocumentUploader.stories.tsx |
| Test files | ComponentName.test.tsx | DocumentUploader.test.tsx |
No abbreviations: configuration not cfg, document not doc, pipeline not pipe.
---
Component Size Rules
When a component exceeds its maximum, extract in this order: 1. Move logic into a custom hook (co-located: useComponentName.ts) 2. Extract presentation into a child component 3. Move constants to a co-located .constants.ts file
| Atomic Level | Target | Maximum |
|---|---|---|
| Atom | < 30 lines | 50 lines |
| Molecule | < 60 lines | 100 lines |
| Organism | < 100 lines | 150 lines |
| Template | < 50 lines | 100 lines |
| Custom hook | < 50 lines | 80 lines |
---
SOLID Principles for React
See references/component-standards.md for implementation patterns.
Single Responsibility — one component renders one thing. Do not mix data fetching, business logic, and rendering in one component. Extract to a custom hook or service.
Open/Closed — extend components through props and composition; do not modify internals to add behaviour. Use variants and slots rather than adding conditional branches.
Liskov Substitution — a specialised component variant must be substitutable for its base. Do not create prop combinations that produce surprising or broken behaviour.
Interface Segregation — no component accepts props it does not use. Split prop interfaces when a component is used in two unrelated contexts with different requirements.
Dependency Inversion — components depend on prop abstractions (callback functions, typed data), not concrete domain services. Never import a domain service directly into a component.
---
State Co-location Rule
State lives at the lowest level that owns it:
| State Type | Location |
|---|---|
| Local UI state (open/closed, hover, current tab) | Component useState |
| Form state | react-hook-form or component useReducer |
| Feature-scoped shared state | Feature-level Zustand slice or React Context |
| Server state (API data, caching) | React Query (useQuery, useMutation) |
| Global app state | App-level Zustand store |
Never lift state higher than necessary. Never manage API response data in Zustand.
---
UI Quality Gates
tsc --noEmit # type check
eslint src/ # lint (includes react, jsx-a11y, react-hooks plugins)
prettier --check src/ # format check
vitest run # unit + component tests (React Testing Library)
vitest run --coverage # coverage report (80% line coverage minimum for ol_ui_library)
playwright test # end-to-end journey tests
storybook build # confirm all stories compile without errors
npx lighthouse <url> # Core Web Vitals: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1All gates must pass before declaring implementation complete. Report failures — do not suppress them with eslint-disable or @ts-ignore without a documented justification.
---
Implement Mode: UI Construction Order
Follow this order within a UI feature to minimise rework:
1. Design tokens and constants — colours, spacing, copy strings as named constants 2. Types and interfaces — prop interfaces, state types, API response shapes 3. Custom hooks — data fetching, local logic, side effects 4. Atoms — smallest reusable elements (or confirm from ol_ui_library) 5. Molecules — combinations of atoms 6. Organisms — complex compositions of molecules 7. Templates — structural layout compositions 8. Page / feature entry point — wire together the template with data 9. Tests — React Testing Library unit tests + Playwright journey tests 10. Storybook stories — if contributing to or using ol_ui_library
---
Review Mode: UI-Specific Checks
When reviewing UI code, apply these checks in addition to the data-engineer review checklist:
| Category | Key Questions |
|---|---|
| Component size | Within size limits? Logic extracted to hooks? |
| SOLID compliance | One responsibility? Domain logic absent from render? Props interface not over-specified? |
| State co-location | Server state in React Query? No unnecessary lifts to global store? |
| Accessibility | jsx-a11y rules passing? WCAG 2.2 AA met (POUR)? :focus-visible used? No outline: none? prefers-reduced-motion respected? ARIA labels present? |
| Naming | handle* for internal handlers, on* for callback props, use* for hooks? |
| ol_ui_library usage | Using library components where they exist? Not re-implementing atoms? |
| Test quality | Tests cover states (loading, error, empty, populated)? Testing behaviour not implementation? |
| Type safety | No any? Event handlers typed? Prop interfaces explicit? |
---
Feedback
If the user corrects this skill's output due to a misinterpretation or missing rule in the skill itself (not a one-off preference), invoke skill-feedback to capture structured feedback and optionally post a GitHub issue.
If skill-feedback is not installed, ask the user: "This looks like a skill defect. Would you like to install the `skill-feedback` skill to report it?" If the user declines, continue without feedback capture.
ui-engineer — Contract
Portability: platform-adaptable Requires: [ol-ui-library] Extends: javascript-data-engineer (a portable skill — read it first) Reference implementation: OntoLedgy (ol_ui_library)
Purpose
Implement or review React/TypeScript frontend code from an approved ui-architect design — components, custom hooks, UX journeys, data visualisations, Storybook stories, and tests — and contribute to ol_ui_library.
Inputs
| Input | Required | Form | Notes |
|---|---|---|---|
| Approved UI design | yes | from ui-architect | pattern, state strategy, journeys, chart library, library extensions resolved upstream |
All javascript-data-engineer inputs | yes | spec / code | TypeScript conventions apply |
ol_ui_library | yes | component package | used where components exist; not re-implemented |
Outputs
React/TypeScript implementation following the Atomic construction order, with RTL unit tests, Playwright journey tests, and Storybook stories; or a UI review gap report.
Invariants
- Inherits every
javascript-data-engineerinvariant; defaults to TypeScript +
React.
- No architectural decisions made here — pattern, state strategy, journeys,
and chart library come from the approved design.
- SOLID for React: one responsibility per component; **no domain logic in
render**; components depend on prop abstractions, never concrete domain services.
- State co-location: state lives at the lowest owning level; **server state
in React Query**, never in Zustand.
- Component size limits enforced per atomic level (extract to hook → child →
constants).
- `ol_ui_library` reused where components exist; no re-implementing atoms.
- WCAG 2.2 AA / jsx-a11y pass; quality gates (tsc, eslint, prettier, vitest,
playwright, storybook, lighthouse) all pass before "complete".
Platform dependencies → adaptation contract
| Dependency | Used for | OL reference backing | Substitute must provide |
|---|---|---|---|
ol-ui-library | The component catalogue reused/extended during implementation | ol_ui_library (see references/ol-ui-library.md) | Any React component library with a catalogue to reuse and a Storybook contribution path. The React/SOLID patterns, state co-location rules, journey implementations, and quality gates are platform-independent; only the component library is substituted. With no library, implement atoms locally — but the "reuse before build" invariant then has nothing to check against, so state that explicitly. |
Boundaries
- Does not make architectural decisions (
ui-architectdoes). - Does not own backend/domain code.
Conformance check
1. Implementation traces to an approved ui-architect design; no new architectural decisions introduced. 2. No domain logic in render; components depend on prop abstractions. 3. Server state in React Query; state co-located at the lowest owning level. 4. Component size limits respected; ol_ui_library (or substitute) reused. 5. All UI quality gates pass.
Component Standards: SOLID Principles for React
Single Responsibility Principle
Rule: One component renders one thing. Do not mix data fetching, business logic, and rendering in the same component.
Before (violates SRP):
// UserProfile does too many things
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); });
}, [userId]);
const handleDeactivate = async () => {
await fetch(`/api/users/${userId}/deactivate`, { method: 'POST' });
setUser(prev => prev ? { ...prev, active: false } : null);
};
if (loading) return <Spinner />;
return <div>...</div>;
}After (SRP applied):
// Logic in a hook
function useUserProfile(userId: string) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { mutate: deactivate } = useMutation({
mutationFn: () => deactivateUser(userId),
});
return { user, isLoading, deactivate };
}
// Component only renders
function UserProfile({ userId }: { userId: string }) {
const { user, isLoading, deactivate } = useUserProfile(userId);
if (isLoading) return <Spinner />;
return <UserProfileView user={user} onDeactivate={deactivate} />;
}---
Open/Closed Principle
Rule: Components are open for extension through props/composition; closed for internal modification to add new behaviour.
Before (violates OCP — conditionals for every new variant):
function Button({ label, type }: { label: string; type: 'primary' | 'danger' | 'ghost' }) {
const className = type === 'primary' ? 'btn-primary'
: type === 'danger' ? 'btn-danger'
: 'btn-ghost';
// Adding a new type requires modifying this component
return <button className={className}>{label}</button>;
}After (OCP — extend via props without modifying internals):
interface ButtonProps {
readonly children: React.ReactNode;
readonly intent?: 'primary' | 'secondary' | 'danger' | 'ghost';
readonly size?: 'sm' | 'md' | 'lg';
readonly disabled?: boolean;
readonly onClick?: () => void;
}
function Button({ children, intent = 'primary', size = 'md', ...props }: ButtonProps) {
return (
<button
className={styles[`${intent}-${size}`]}
{...props}
>
{children}
</button>
);
}
// New variant: add to the style map, not to the component---
Interface Segregation Principle
Rule: No component accepts props it does not use. When a component is used in multiple unrelated contexts, split its props interface.
Before (violates ISP — many optional props the component may not use):
interface TableProps {
data: Row[];
onEdit?: (row: Row) => void; // Only needed in edit mode
onDelete?: (row: Row) => void; // Only needed in admin mode
onExport?: () => void; // Only needed with export feature
showPagination?: boolean; // Always needed
pageSize?: number; // Always needed
}After (ISP — compose specific interfaces):
interface BaseTableProps {
readonly data: Row[];
readonly pageSize?: number;
}
interface EditableTableProps extends BaseTableProps {
readonly onEdit: (row: Row) => void;
readonly onDelete: (row: Row) => void;
}
interface ExportableTableProps extends BaseTableProps {
readonly onExport: () => void;
}---
Dependency Inversion Principle
Rule: Components depend on prop abstractions (callback functions, typed data interfaces), not concrete domain services. Never import a domain service directly into a component.
Before (violates DIP — component knows about pipeline service):
import { pipelineService } from '../../services/pipelineService';
function PipelineStatusCard({ pipelineId }: { pipelineId: string }) {
const [status, setStatus] = useState<PipelineStatus | null>(null);
useEffect(() => {
pipelineService.getStatus(pipelineId).then(setStatus);
}, [pipelineId]);
return <div>{status?.label}</div>;
}After (DIP — component depends only on its props contract):
interface PipelineStatusCardProps {
readonly status: PipelineStatus;
readonly label: string;
readonly onRetry?: () => void;
}
function PipelineStatusCard({ status, label, onRetry }: PipelineStatusCardProps) {
return <div>{label}</div>;
}
// Data fetching is the container/hook's concern, not the component's---
Component File Co-location
Keep related files together. A component's supporting files live alongside it:
DocumentUploader/
DocumentUploader.tsx Component
DocumentUploader.test.tsx React Testing Library tests
DocumentUploader.stories.tsx Storybook stories
DocumentUploader.module.css CSS Modules styles
useDocumentUpload.ts Co-located hook (if only used by this component)
documentUploader.constants.ts Constants (copy strings, limits, config values)
index.ts Public export: export { DocumentUploader }Rule: If a hook or constant is used by only one component, co-locate it. If used by two or more, move it to a shared location.
---
Props Interface Rules
// All props readonly — components do not mutate their inputs
interface DocumentUploaderProps {
readonly acceptedTypes: string[]; // What file types are allowed
readonly maxFileSizeBytes: number; // Hard limit per file
readonly maxFileCount?: number; // Optional: limit number of files
readonly onFilesSelected: (files: File[]) => void; // Callback, not imperative
readonly onUploadComplete?: (results: UploadResult[]) => void;
readonly isUploading?: boolean; // Controlled loading state
readonly disabled?: boolean;
}Rules:
- All props
readonly - Optional props use
?— never useundefinedas a default value explicitly - Callback props follow
on+ noun convention - No
anyin props — use specific types or generics - No prop drilling beyond 2 levels — pass via Context or lift state
---
Custom Hook Rules
// Hook encapsulates logic; returns only what the component needs
function useDocumentUpload(options: UseDocumentUploadOptions) {
const { maxFileSizeBytes, acceptedTypes, onComplete } = options;
const [files, setFiles] = useState<FileWithStatus[]>([]);
const [uploadState, setUploadState] = useState<UploadState>('idle');
const handleFilesAdded = useCallback((newFiles: File[]) => {
const validated = validateFiles(newFiles, { maxFileSizeBytes, acceptedTypes });
setFiles(prev => [...prev, ...validated]);
}, [maxFileSizeBytes, acceptedTypes]);
const handleUpload = useCallback(async () => {
setUploadState('uploading');
// ... upload logic
}, [files, onComplete]);
return {
files,
uploadState,
handleFilesAdded,
handleUpload,
removeFile: (index: number) => setFiles(prev => prev.filter((_, i) => i !== index)),
} as const;
}Rules:
- Always starts with
use - Returns a
constobject (prevents accidental mutation of the return value) - No JSX inside hooks
- Side effects go in
useEffectwith proper dependency arrays useCallbackon handlers that are passed to child components
---
Forbidden Patterns
| Pattern | Why Forbidden | Alternative |
|---|---|---|
| Domain service imports in components | Couples UI to business logic | Inject via props or custom hook |
any in props or state | Loses type safety | Use unknown and narrow, or specific types |
| Inline styles (style={{ }}) | Hard to theme, hard to override | CSS Modules or design tokens |
!important in CSS | Defeats cascade | Increase selector specificity correctly |
useEffect for data derivation | Causes extra renders | Use useMemo for derived values |
index as React key | Causes incorrect reconciliation on reorder | Use stable entity IDs |
| Nested ternaries in JSX | Unreadable | Extract to a named variable or helper function |
outline: none without replacement | Destroys keyboard accessibility | Replace with :focus-visible ring using design tokens |
transition: all | Animates layout properties; causes CLS and jank | List only transform and opacity explicitly |
user-scalable=no in viewport meta | Blocks zoom for low-vision users | Never set; let users zoom |
<div onClick={...}> | Not keyboard accessible; not announced by screen readers | Use <button> or <a> with correct role |
| Hardcoded date formats | Locale-incompatible | Use Intl.DateTimeFormat with explicit locale |
---
Implementation Micro-rules
Specific rules derived from Vercel web-interface-guidelines and addyosmani/web-quality-skills. These are quick wins that prevent the most common accessibility, performance, and UX bugs.
Focus and Keyboard
/* Always use :focus-visible — only shows ring for keyboard, not mouse */
.button:focus-visible {
outline: 2px solid var(--shadow-focus);
outline-offset: 2px;
}
/* Never do this — removes focus ring for keyboard users */
.button:focus { outline: none; }// Icon-only buttons always need aria-label
<button aria-label="Close dialog" onClick={onClose}>
<CloseIcon aria-hidden="true" />
</button>Forms
// Inputs need autocomplete + name for password managers and autofill
<input
type="email"
name="email"
autoComplete="email"
aria-label="Email address"
/>
// Always associate label with input — never rely on placeholder alone
<label htmlFor="pipeline-name">Pipeline name</label>
<input id="pipeline-name" type="text" />
// Never block paste — users paste passwords, OTP codes, and data
// Remove: onPaste={e => e.preventDefault()}
// On form submit with errors: focus the first error field
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const firstError = formRef.current?.querySelector('[aria-invalid="true"]');
(firstError as HTMLElement)?.focus();
}Animation
/* Only animate transform and opacity — compositor-thread only */
.panel {
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Never animate layout properties */
/* BAD: .panel { transition: height 200ms; } */
/* BAD: .panel { transition: margin 200ms; } */
/* Always respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.panel { transition: none; }
}Typography
/* Balance heading line breaks — prevents orphaned words */
h1, h2, h3 { text-wrap: balance; }
/* Align number columns in tables and dashboards */
.metric-value, td.numeric { font-variant-numeric: tabular-nums; }Navigation and URL State
// Deep-linkable state belongs in the URL, not in useState
// Use <Link> for navigation — never <div onClick={() => navigate(...)}>
// Bad: state in React, not URL — not shareable, not bookmarkable
const [activeTab, setActiveTab] = useState('overview');
// Good: state in URL — shareable, bookmarkable, browser-back works
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get('tab') ?? 'overview';Destructive Actions
// Always confirm before irreversible operations
function DeletePipelineButton({ onConfirm }: { onConfirm: () => void }) {
const [showConfirm, setShowConfirm] = useState(false);
return (
<>
<Button intent="danger" onClick={() => setShowConfirm(true)}>
Delete pipeline
</Button>
{showConfirm && (
<ConfirmationModal
title="Delete pipeline?"
description="This action cannot be undone."
confirmLabel="Delete"
onConfirm={onConfirm}
onCancel={() => setShowConfirm(false)}
/>
)}
</>
);
}Dynamic Content
// Announce non-critical async updates to screen readers
<div aria-live="polite" aria-atomic="true">
{uploadStatus && <p>{uploadStatus}</p>}
</div>
// Announce errors immediately
<div role="alert">
{error && <p>{error.message}</p>}
</div>---
Compound Component Pattern
Use when a component family needs flexible internal composition without a proliferation of boolean props. Replaces: <Tabs activeTab="x" showBorder hideIcons verticalLayout />.
// Context holds shared state
interface TabsContextValue {
readonly activeTab: string;
readonly setActiveTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Must be used inside <Tabs>');
return ctx;
}
// Root component owns the state
function Tabs({ defaultTab, children }: { defaultTab: string; children: React.ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className={styles.tabs}>{children}</div>
</TabsContext.Provider>
);
}
// Sub-components consume the context
function TabList({ children }: { children: React.ReactNode }) {
return <div role="tablist" className={styles.tabList}>{children}</div>;
}
function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab, setActiveTab } = useTabsContext();
return (
<button
role="tab"
aria-selected={activeTab === id}
onClick={() => setActiveTab(id)}
className={`${styles.tab} ${activeTab === id ? styles.active : ''}`}
>
{children}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab } = useTabsContext();
if (activeTab !== id) return null;
return <div role="tabpanel">{children}</div>;
}
// Attach sub-components to root for ergonomic usage
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Consumer usage — composition, no boolean props
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="stages">Stages</Tabs.Tab>
<Tabs.Tab id="logs">Logs</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview"><OverviewContent /></Tabs.Panel>
<Tabs.Panel id="stages"><StagesContent /></Tabs.Panel>
<Tabs.Panel id="logs"><LogsContent /></Tabs.Panel>
</Tabs>When to use compound components: Navigation (Tabs, Accordion, Menu), form field families, multi-step containers. When not to: Simple standalone components — compound pattern adds ceremony that is not justified for a single component.
---
React 19 Patterns
use() Hook for Promises
// React 19: unwrap a promise inside a component (must be wrapped in Suspense)
import { use } from 'react';
function PipelineDetails({ pipelinePromise }: { pipelinePromise: Promise<Pipeline> }) {
const pipeline = use(pipelinePromise); // Suspends until resolved
return <div>{pipeline.name}</div>;
}
// Parent wraps with Suspense
<Suspense fallback={<PipelineSkeleton />}>
<PipelineDetails pipelinePromise={fetchPipeline(id)} />
</Suspense>Form Actions (React 19)
// React 19: useActionState replaces manual isPending + error state for form submissions
import { useActionState } from 'react';
async function submitPipelineAction(
previousState: ActionState,
formData: FormData,
): Promise<ActionState> {
const name = formData.get('name') as string;
try {
await createPipeline({ name });
return { status: 'success' };
} catch (error) {
return { status: 'error', message: 'Failed to create pipeline' };
}
}
function CreatePipelineForm() {
const [state, action, isPending] = useActionState(submitPipelineAction, { status: 'idle' });
return (
<form action={action}>
<input name="name" type="text" required />
{state.status === 'error' && <p role="alert">{state.message}</p>}
<Button type="submit" loading={isPending}>Create</Button>
</form>
);
}useOptimistic for Instant Feedback
// Optimistically update UI before server confirms
import { useOptimistic } from 'react';
function PipelineList({ pipelines, onDelete }: PipelineListProps) {
const [optimisticPipelines, removeOptimistically] = useOptimistic(
pipelines,
(current, idToRemove: string) => current.filter(p => p.id !== idToRemove),
);
const handleDelete = async (id: string) => {
removeOptimistically(id); // Instant UI update
await deletePipeline(id); // Server call — UI already reflects the change
};
return (
<ul>
{optimisticPipelines.map(p => (
<PipelineItem key={p.id} pipeline={p} onDelete={handleDelete} />
))}
</ul>
);
}Data Visualisation Implementation
Library Conventions
Use the library specified in the approved ui-architect design. The default for new projects is Recharts. See the architect's data-visualisation-strategy.md for the selection rationale and the library decision.
---
Recharts Patterns (Default)
Base Chart Wrapper
Wrap every chart in a responsive container and extract chart logic to a custom hook:
interface LineChartProps {
readonly data: TimeSeriesPoint[];
readonly xAxisKey: string;
readonly yAxisKey: string;
readonly label: string;
readonly height?: number;
}
function TimeSeriesLineChart({ data, xAxisKey, yAxisKey, label, height = 300 }: LineChartProps) {
return (
<figure aria-label={label} role="img">
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-neutral-200)" />
<XAxis dataKey={xAxisKey} tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey={yAxisKey}
stroke="var(--color-brand-primary)"
dot={false} // Remove dots for large datasets
isAnimationActive={false} // Disable for real-time data
/>
</LineChart>
</ResponsiveContainer>
<figcaption className={styles.srOnly}>{label}</figcaption>
</figure>
);
}Chart Type Implementations
Bar Chart (categorical comparison):
function CategoryBarChart({ data, categoryKey, valueKey, label }: BarChartProps) {
return (
<figure aria-label={label} role="img">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
<Tooltip />
<Bar dataKey={valueKey} fill="var(--color-brand-primary)" />
</BarChart>
</ResponsiveContainer>
<figcaption className={styles.srOnly}>{label}</figcaption>
</figure>
);
}KPI Card (single metric):
interface KpiCardProps {
readonly label: string;
readonly value: string | number;
readonly trend?: 'up' | 'down' | 'neutral';
readonly intent?: 'success' | 'danger' | 'warning' | 'neutral';
}
function KpiCard({ label, value, trend, intent = 'neutral' }: KpiCardProps) {
return (
<article className={`${styles.kpiCard} ${styles[intent]}`} aria-label={`${label}: ${value}`}>
<span className={styles.label}>{label}</span>
<span className={styles.value}>{value}</span>
{trend && <TrendIndicator direction={trend} aria-hidden="true" />}
</article>
);
}---
Real-Time Chart Implementation
Data Buffer Hook
Manage the rolling window of live data in a hook — keep it out of the component:
interface RealTimeBufferOptions {
readonly maxPoints: number;
readonly wsUrl: string;
readonly parseMessage: (event: MessageEvent) => DataPoint;
}
function useRealTimeBuffer({ maxPoints, wsUrl, parseMessage }: RealTimeBufferOptions) {
const [buffer, setBuffer] = useState<DataPoint[]>([]);
const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const wsRef = useRef<WebSocket | null>(null);
const reconnectDelayRef = useRef(1000);
const connect = useCallback(() => {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setConnectionStatus('connected');
reconnectDelayRef.current = 1000; // Reset backoff on successful connection
};
ws.onmessage = event => {
const point = parseMessage(event);
setBuffer(prev => {
const updated = [...prev, point];
return updated.length > maxPoints ? updated.slice(-maxPoints) : updated;
});
};
ws.onclose = () => {
setConnectionStatus('disconnected');
// Exponential backoff reconnection
setTimeout(() => {
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 30000);
connect();
}, reconnectDelayRef.current);
};
}, [wsUrl, maxPoints, parseMessage]);
useEffect(() => {
connect();
return () => wsRef.current?.close();
}, [connect]);
return { buffer, connectionStatus } as const;
}Real-Time Line Chart
function LiveMetricChart({ wsUrl, label }: LiveMetricChartProps) {
const { buffer, connectionStatus } = useRealTimeBuffer({
maxPoints: 500,
wsUrl,
parseMessage: event => JSON.parse(event.data as string) as DataPoint,
});
// Memoize — only re-render when buffer reference changes
const chartData = useMemo(() => buffer, [buffer]);
return (
<div className={styles.liveChartWrapper}>
<ConnectionStatusBadge status={connectionStatus} />
<TimeSeriesLineChart
data={chartData}
xAxisKey="timestamp"
yAxisKey="value"
label={label}
/>
</div>
);
}---
ECharts Patterns (Large Datasets)
Use ECharts when dataset exceeds 2,000 live points or SVG performance degrades:
import ReactECharts from 'echarts-for-react';
function LargeDatasetChart({ data, label }: LargeDatasetChartProps) {
const option = useMemo(() => ({
xAxis: { type: 'category', data: data.map(d => d.timestamp) },
yAxis: { type: 'value' },
series: [{
data: data.map(d => d.value),
type: 'line',
smooth: true,
symbol: 'none', // No dots — critical for performance at scale
animation: false, // Disable animation for large/real-time data
}],
tooltip: { trigger: 'axis' },
}), [data]);
return (
<figure aria-label={label} role="img">
<ReactECharts option={option} style={{ height: 300 }} />
<figcaption className={styles.srOnly}>{label}</figcaption>
</figure>
);
}---
Accessibility for Charts
Every chart must be accessible. A chart that is only a visual element is not sufficient:
// Pattern: figure + figcaption + data table fallback
function AccessibleChart({ data, label, chartElement }: AccessibleChartProps) {
const [showTable, setShowTable] = useState(false);
return (
<figure role="img" aria-label={label}>
{chartElement}
<figcaption>
<button
className={styles.dataTableToggle}
onClick={() => setShowTable(prev => !prev)}
aria-expanded={showTable}
aria-controls="chart-data-table"
>
{showTable ? 'Hide data table' : 'Show data as table'}
</button>
{showTable && (
<table id="chart-data-table">
{/* Render data in tabular form for screen readers */}
</table>
)}
</figcaption>
</figure>
);
}Rules:
- Every chart has
role="img"andaria-labeldescribing what it shows - Every chart has a
<figcaption>(can besr-onlyif the title is visually clear) - Complex charts (multi-series, interactive) offer a data table view
- Colour is never the only encoding — use labels, patterns, or shapes as secondary encoding
- Interactive chart elements (tooltips, zoom) are keyboard accessible
---
Empty and Loading States
Design these before implementing the chart:
function ChartPanel({ isLoading, error, data, label }: ChartPanelProps) {
if (isLoading) {
return <ChartSkeleton height={300} aria-label={`Loading ${label}`} />;
}
if (error) {
return (
<ChartError
message="Could not load chart data"
onRetry={handleRetry}
aria-label={`Error loading ${label}`}
/>
);
}
if (data.length === 0) {
return (
<ChartEmpty
message="No data available for this period"
aria-label={`No data for ${label}`}
/>
);
}
return <TimeSeriesLineChart data={data} label={label} />;
}---
Performance Rules
| Rule | Reason |
|---|---|
isAnimationActive={false} for real-time data | Animations block re-renders at high update frequency |
symbol="none" for line charts with > 100 points | Individual dots are expensive to render at scale |
useMemo for chart data derivation | Prevents unnecessary re-computation on unrelated state changes |
React.memo on chart components | Prevents re-render when parent state changes but chart data has not |
Single ResponsiveContainer per chart | Multiple responsive containers in one render are expensive |
| Virtualise tables with > 1,000 rows | Use @tanstack/react-virtual — rendering all rows at once is O(n) render cost |
ol_ui_library: Usage and Contribution
Core Principle
ol_ui_library is a platform library — the UI equivalent of bclearer_pdk. Before implementing any UI component, check if it already exists in the library.
A custom component built alongside an existing library component is a violation. Justify explicitly in the PR if you are not using an available library component.
---
Component Catalogue
Atoms
| Component | Props Summary | When to Use |
|---|---|---|
Button | intent, size, disabled, loading, onClick | All interactive buttons |
Input | type, value, placeholder, error, disabled, onChange | All text/number inputs |
Label | htmlFor, required | All form field labels |
Icon | name, size, aria-hidden | Decorative or semantic icons |
Badge | label, intent | Status labels, counts |
Spinner | size, aria-label | Loading states |
Avatar | src, alt, size, initials | User identity display |
Tooltip | content, placement, children | Contextual help on hover/focus |
Molecules
| Component | Props Summary | When to Use |
|---|---|---|
FormField | label, error, required, children | Wraps any input with label + error |
SearchBox | value, placeholder, onSearch, onClear | Search inputs with clear control |
FileDropZone | acceptedTypes, maxSizeBytes, onFilesSelected | File upload drop target |
ProgressBar | value (0–100), label, intent | Upload progress, loading states |
Alert | intent, title, message, onDismiss | Inline feedback messages |
Pagination | currentPage, totalPages, onPageChange | Table and list pagination |
Organisms
| Component | Props Summary | When to Use |
|---|---|---|
DataTable | columns, data, onSort, onFilter, isLoading | All tabular data display |
NavigationBar | items, activeItem, onNavigate, user | Application navigation |
DocumentUploader | acceptedTypes, maxSizeBytes, onUploadComplete | File upload workflows |
ChartPanel | isLoading, error, title, children | Chart container with states |
WizardContainer | steps, currentStep, onNext, onBack | Multi-step form wrapper |
Templates
| Component | Props Summary | When to Use |
|---|---|---|
DashboardTemplate | header, sidebar, main, footer | Standard dashboard layout |
WizardTemplate | stepIndicator, stepContent, navigation | Full wizard page layout |
ResultsTemplate | summary, explorer, visualisation | Pipeline results layout |
EmptyStateTemplate | icon, title, description, action | Empty/no-data states |
---
Usage Patterns
Correct: Using Library Components
import { Button, FormField, Input } from '@ontoledgy/ol-ui-library';
function LoginForm({ onSubmit }: { onSubmit: (credentials: Credentials) => void }) {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormField label="Email" required error={errors.email?.message}>
<Input
type="email"
{...register('email', { required: 'Email is required' })}
/>
</FormField>
<Button type="submit" intent="primary" loading={isSubmitting}>
Sign in
</Button>
</form>
);
}Incorrect: Re-implementing a Library Component
// VIOLATION: Button already exists in ol_ui_library
function PrimaryButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={onClick}>
{label}
</button>
);
}---
Contributing to ol_ui_library
When to Contribute
Contribute to ol_ui_library when:
- The same component pattern is needed in 2+ product features
- The component encapsulates accessibility logic that should not be duplicated
- The pattern is general enough to be useful across products
Do NOT contribute to the library when:
- The component is domain-specific (e.g.
PipelineStageCard,DocumentClassificationBadge) - The component depends on product-specific types or services
Contribution Steps
1. Get a design first Component contributions require an approved ui-architect Library Maintenance Mode design before implementation begins. Never start coding a library component without an approved spec.
2. Implementation checklist
- [ ] Props interface is
readonlythroughout - [ ] No domain logic or service imports
- [ ] All states implemented: default, hover, focus, active, disabled, error, loading
- [ ] All variants implemented (size, intent, theme)
- [ ] CSS uses design tokens only — no hardcoded values
- [ ] WCAG 2.1 AA accessibility implemented (contrast, keyboard, ARIA)
- [ ]
prefers-reduced-motionrespected for animations
3. Storybook story Every contribution requires a story file:
// DocumentUploader.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { DocumentUploader } from './DocumentUploader';
const meta: Meta<typeof DocumentUploader> = {
title: 'Organisms/DocumentUploader',
component: DocumentUploader,
parameters: { layout: 'padded' },
};
export default meta;
type Story = StoryObj<typeof DocumentUploader>;
export const Default: Story = {
args: {
acceptedTypes: ['.pdf', '.csv'],
maxFileSizeBytes: 5 * 1024 * 1024,
},
};
export const WithFilesSelected: Story = {
args: { ...Default.args },
play: async ({ canvasElement }) => {
// Interaction test: simulate file selection
},
};
export const Uploading: Story = {
args: { ...Default.args, isUploading: true },
};
export const Disabled: Story = {
args: { ...Default.args, disabled: true },
};4. Semver bump
- New optional prop →
MINOR - New component →
MINOR - Renamed or removed prop →
MAJOR(requires migration guide) - Bug fix →
PATCH
5. Changelog entry Every contribution adds an entry to CHANGELOG.md:
## [1.4.0] - 2026-04-02
### Added
- `DocumentUploader` organism: file drop zone with multi-file support, per-file progress,
and validation. Supports drag-drop and browser file picker. WCAG 2.1 AA compliant.---
Design Token Usage in Components
// Correct: reference tokens via CSS custom properties
// In DocumentUploader.module.css:
.dropZone {
border: 2px dashed var(--color-neutral-300);
border-radius: var(--radius-md);
padding: var(--space-8);
background: var(--color-neutral-50);
}
.dropZone:focus-within,
.dragOver {
border-color: var(--color-brand-primary);
background: var(--color-brand-50);
outline: none;
box-shadow: 0 0 0 3px var(--shadow-focus);
}// Incorrect: hardcoded values
.dropZone {
border: 2px dashed #d1d5db; // VIOLATION: use var(--color-neutral-300)
padding: 32px; // VIOLATION: use var(--space-8)
}Frontend Performance
Performance is a design constraint, not an afterthought. The three measurable targets that define a "fast" experience are the Core Web Vitals. Everything else in this file exists to hit them.
---
Core Web Vitals — Hard Targets
Google evaluates these at the 75th percentile across all page visits.
| Metric | What it measures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP — Largest Contentful Paint | Loading performance | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP — Interaction to Next Paint | Responsiveness | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS — Cumulative Layout Shift | Visual stability | ≤ 0.1 | ≤ 0.25 | > 0.25 |
These are not aspirational — they are pass/fail gates before a feature ships.
Measuring in the Browser
// Observe LCP in development
const observer = new PerformanceObserver(list => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1];
console.log('LCP:', lcp.startTime, 'ms');
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });Use the web-vitals library for real-user monitoring in production:
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);---
LCP — Loading Performance
Target: ≤ 2.5s
| Rule | Implementation |
|---|---|
| Server response < 800ms | Measure TTFB; cache at CDN edge |
| Preload the LCP element | <link rel="preload" as="image"> for hero images |
| Inline above-fold CSS | No render-blocking stylesheet for critical styles |
| No render-blocking JS on the critical path | defer or async on non-critical scripts |
| Compress images correctly | WebP/AVIF; correct sizes attribute on <img> |
| Self-host critical fonts | Avoids third-party DNS lookup on the critical path |
// Correct: explicit dimensions prevent layout reflow; priority loads immediately
<Image
src="/hero.webp"
width={1200}
height={600}
priority // Next.js: preloads this image
alt="Dashboard overview"
/>---
INP — Interaction Responsiveness
Target: ≤ 200ms (event handler completes and browser paints within 200ms)
Break Long Tasks
Any JS task that runs > 50ms blocks the main thread and degrades INP. Break long tasks:
// Bad: synchronous heavy work blocks input handling
function processResults(data: RawResult[]) {
return data.map(item => expensiveTransform(item)); // 300ms of synchronous work
}
// Good: yield to the browser between chunks
async function processResultsAsync(data: RawResult[]) {
const chunkSize = 100;
const results: ProcessedResult[] = [];
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
results.push(...chunk.map(expensiveTransform));
await new Promise(resolve => setTimeout(resolve, 0)); // Yield
}
return results;
}Provide Immediate Visual Feedback
Users perceive < 100ms as instant. For operations that take longer, show feedback immediately:
function SubmitButton({ onSubmit }: SubmitButtonProps) {
const [isPending, setIsPending] = useState(false);
const handleClick = async () => {
setIsPending(true); // Immediate feedback within the same event
await onSubmit();
setIsPending(false);
};
return <Button loading={isPending} onClick={handleClick}>Submit</Button>;
}Defer Non-Critical Work
// Non-critical analytics work deferred until browser is idle
requestIdleCallback(() => {
trackPageView({ page: location.pathname });
});
// Visual updates deferred to next frame
requestAnimationFrame(() => {
element.classList.add('highlighted');
});---
CLS — Visual Stability
Target: ≤ 0.1 (elements should not shift unexpectedly after initial paint)
| Rule | Implementation |
|---|---|
| Always set explicit image dimensions | width + height or aspect-ratio in CSS |
| Reserve space for async content | Skeleton loaders matching the final layout size |
| Avoid inserting content above existing content | Insert dynamic content at the bottom or in reserved space |
Animate with transform only | transform: translateY() not margin-top — transforms don't cause layout |
Use font-display: optional for non-critical fonts | Prevents FOUT-triggered layout shift |
/* Reserve space for an image before it loads */
.hero-image {
aspect-ratio: 16 / 9;
width: 100%;
background: var(--color-neutral-100); /* Placeholder colour */
}---
React Performance: Eliminating Waterfalls
A waterfall happens when requests are made sequentially when they could be parallel. This is the single highest-impact React performance issue.
Parallel Data Fetching
// Bad: sequential — request 2 waits for request 1 to complete
function PipelineDashboard({ pipelineId }: { pipelineId: string }) {
const { data: pipeline } = useQuery(pipelineQuery(pipelineId));
const { data: stages } = useQuery(stagesQuery(pipelineId)); // Waits for pipeline
const { data: metrics } = useQuery(metricsQuery(pipelineId)); // Waits for stages
}
// Good: parallel — all three requests fire simultaneously
function PipelineDashboard({ pipelineId }: { pipelineId: string }) {
const results = useQueries({
queries: [
pipelineQuery(pipelineId),
stagesQuery(pipelineId),
metricsQuery(pipelineId),
],
});
const [pipeline, stages, metrics] = results;
}Prefetch on Hover
const queryClient = useQueryClient();
function PipelineListItem({ pipeline }: { pipeline: PipelineSummary }) {
const prefetch = useCallback(() => {
queryClient.prefetchQuery({
queryKey: ['pipeline', pipeline.id],
queryFn: () => fetchPipeline(pipeline.id),
staleTime: 10_000,
});
}, [pipeline.id]);
return (
<li onMouseEnter={prefetch} onFocus={prefetch}>
<Link to={`/pipelines/${pipeline.id}`}>{pipeline.name}</Link>
</li>
);
}---
Bundle Size Optimisation
Code Splitting
// Lazy-load routes — each route is a separate chunk
const PipelineDashboard = lazy(() => import('./pages/pipeline/PipelineDashboard'));
const ResultsReview = lazy(() => import('./pages/results/ResultsReview'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/pipelines/:id" element={<PipelineDashboard />} />
<Route path="/results/:id" element={<ResultsReview />} />
</Routes>
</Suspense>
);
}Lazy-Load Heavy Libraries
// Bad: ECharts loaded on every page even when not needed
import ReactECharts from 'echarts-for-react';
// Good: loaded only when the chart component mounts
const ReactECharts = lazy(() => import('echarts-for-react'));Bundle Analysis
Run after each significant dependency addition:
npx vite-bundle-visualizer # Vite projects
npx @next/bundle-analyzer # Next.js projectsRed flags to investigate:
- A single chunk > 200 KB (gzipped)
- The same dependency appearing in multiple chunks (should be shared)
- Development-only packages (e.g.
@storybook/*) in the production bundle
---
Re-render Memoisation
Memoisation has a cost — apply it only where profiling confirms a problem.
When to Use React.memo
// Only memo components that: re-render frequently AND are expensive to render
const StageCard = React.memo(function StageCard({ stage }: StageCardProps) {
// Expensive render: lots of DOM nodes or complex calculations
return <div>...</div>;
});
// No memo needed: cheap render, infrequent updates
function StatusBadge({ status }: StatusBadgeProps) {
return <span className={styles[status]}>{status}</span>;
}useMemo for Expensive Derivations
// Good: expensive filter/sort runs only when data or query changes
const filteredResults = useMemo(
() => results.filter(r => r.label.includes(searchQuery)).sort(byDate),
[results, searchQuery],
);
// Bad: useMemo for trivial derivations adds overhead without benefit
const label = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// Just write: const label = `${firstName} ${lastName}`;State Colocation Prevents Unnecessary Re-renders
// Bad: search state at page level re-renders the whole page on each keystroke
function ResultsPage() {
const [searchQuery, setSearchQuery] = useState('');
return (
<>
<ExpensiveHeader /> {/* Re-renders on every keystroke */}
<SearchBox value={searchQuery} onChange={setSearchQuery} />
<ResultsTable query={searchQuery} />
</>
);
}
// Good: search state co-located with what needs it
function ResultsSection() {
const [searchQuery, setSearchQuery] = useState('');
return (
<>
<SearchBox value={searchQuery} onChange={setSearchQuery} />
<ResultsTable query={searchQuery} />
</>
);
}---
List Virtualisation
Rule: Virtualise any list or table with > 50 rows. Rendering all rows to the DOM is O(n) in render cost and causes significant scroll jank above ~200 rows.
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualResultsTable({ results }: { results: ProcessingResult[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: results.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // Estimated row height in px
overscan: 5, // Rows to render outside the visible area
});
return (
<div ref={parentRef} className={styles.tableContainer}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
}}
>
<ResultRow result={results[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}---
Image and Font Optimisation
Images
// Always specify width + height (prevents CLS)
// Use next/image or equivalent for automatic format negotiation (WebP/AVIF)
<Image
src="/chart-thumbnail.png"
width={400}
height={225}
loading="lazy" // Below-fold images: lazy load
alt="Pipeline run results"
/>
// Above-fold hero: eager + preload
<Image src="/hero.webp" width={1200} height={600} priority alt="..." />Fonts
/* Self-host fonts to eliminate third-party DNS lookup */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-variable.woff2') format('woff2');
font-display: swap; /* Show fallback immediately; swap when loaded */
font-weight: 100 900; /* Variable font covers all weights in one file */
}<!-- Preload the primary font file in <head> -->
<link rel="preload" href="/fonts/inter-variable.woff2" as="font" type="font/woff2" crossorigin>---
Performance Checklist (Pre-ship)
| Check | Tool | Target |
|---|---|---|
| LCP | Lighthouse / web-vitals | ≤ 2.5s |
| INP | Lighthouse / web-vitals | ≤ 200ms |
| CLS | Lighthouse / web-vitals | ≤ 0.1 |
| No long tasks on critical path | Chrome DevTools Performance tab | No task > 50ms |
| Bundle size | vite-bundle-visualizer | No chunk > 200 KB gzipped |
| No waterfall requests | Chrome DevTools Network tab | Requests fire in parallel |
| Lists virtualised | Code review | > 50 rows → virtualised |
| Images have explicit dimensions | Lighthouse | Zero CLS from images |
UI Tooling
Core Toolchain
Inherits all tooling from javascript-data-engineer (references/tooling.md). The following additions apply specifically to UI work:
| Tool | Purpose | Config File |
|---|---|---|
| Storybook | Component development environment + living documentation | .storybook/ |
| React Testing Library | Component behaviour tests (unit/integration) | vitest.config.ts |
| Playwright | End-to-end journey tests | playwright.config.ts |
| eslint-plugin-jsx-a11y | Accessibility linting | eslint.config.mjs |
| eslint-plugin-react-hooks | Hooks rules enforcement | eslint.config.mjs |
| CSS Modules | Scoped component styles | Built into Vite / Next.js |
---
ESLint Configuration (UI additions)
// eslint.config.mjs — add these plugins to the base config
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import react from 'eslint-plugin-react';
export default [
// ... base config from javascript-data-engineer
{
plugins: {
'react-hooks': reactHooks,
'jsx-a11y': jsxA11y,
react,
},
rules: {
// React hooks rules (mandatory)
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Accessibility (mandatory — UI components must be accessible)
'jsx-a11y/alt-text': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/interactive-supports-focus': 'error',
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/no-noninteractive-element-interactions': 'error',
// React best practices
'react/jsx-key': 'error',
'react/no-array-index-key': 'warn',
'react/no-unstable-nested-components': 'error',
},
},
];---
Storybook Setup
Directory Structure
.storybook/
main.ts Storybook configuration: addons, framework, stories glob
preview.ts Global decorators, parameters, design token injection
src/
**/*.stories.tsx Story files co-located with componentsmain.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-essentials', // Controls, actions, docs, viewport
'@storybook/addon-a11y', // Accessibility tab in Storybook UI
'@storybook/addon-interactions', // Interaction tests via play() functions
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;preview.ts
import type { Preview } from '@storybook/react';
import '../src/styles/tokens.css'; // Inject design tokens globally
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' }, // Auto-detect onXxx props as actions
controls: { matchers: { date: /Date$/i } },
a11y: { config: { rules: [{ id: 'color-contrast', enabled: true }] } },
viewport: {
defaultViewport: 'desktop',
},
},
};
export default preview;---
React Testing Library
Testing Philosophy
Test behaviour, not implementation. A test should survive a refactor of internal component structure as long as the user-visible behaviour is unchanged.
// Test what the user sees and does — not component internals
describe('DocumentUploader', () => {
it('shows an error when an oversized file is dropped', async () => {
const user = userEvent.setup();
render(
<DocumentUploader
acceptedTypes={['.pdf']}
maxFileSizeBytes={1024}
onFilesSelected={vi.fn()}
/>
);
const oversizedFile = new File(['x'.repeat(2048)], 'large.pdf', { type: 'application/pdf' });
const dropZone = screen.getByRole('button', { name: /drop files/i });
await user.upload(dropZone, oversizedFile);
expect(screen.getByText(/exceeds.*1 KB/i)).toBeInTheDocument();
});
});Test Coverage Requirements
| Context | Minimum Coverage |
|---|---|
| ol_ui_library components | 80% line coverage |
| Product feature components | 60% line coverage |
| Custom hooks | 80% line coverage |
What to Test for Each Component
- Happy path: renders correctly with valid props
- Loading state: shows loading indicator when
isLoading={true} - Error state: shows error message with recovery action
- Empty state: shows empty state when data is empty array
- User interactions: click, keyboard, form submission
- Accessibility: run
axevia@axe-core/reactor@testing-library/jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<DocumentUploader {...defaultProps} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Vitest Configuration (React)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
exclude: ['**/*.stories.tsx', '**/*.constants.ts', 'src/test/**'],
},
},
});// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => cleanup());---
Playwright: End-to-End Journey Tests
Configuration
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});Journey Test Pattern
Test complete user journeys — not individual components:
// e2e/document-upload-journey.spec.ts
import { test, expect } from '@playwright/test';
test('user can upload a document and see confirmation', async ({ page }) => {
await page.goto('/upload');
// Step 1: Select file
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: /browse files/i }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('./e2e/fixtures/sample.pdf');
// Step 2: Verify file appears in list
await expect(page.getByText('sample.pdf')).toBeVisible();
// Step 3: Submit upload
await page.getByRole('button', { name: /upload/i }).click();
// Step 4: Verify success state
await expect(page.getByRole('heading', { name: /upload complete/i })).toBeVisible();
await expect(page.getByText('1 file uploaded successfully')).toBeVisible();
});
test('shows an error when a file exceeds the size limit', async ({ page }) => {
await page.goto('/upload');
// ... test error path
});Journey Test Coverage
Write an end-to-end test for each named UX journey:
- Document Upload — happy path + oversized file error + wrong type error
- Pipeline Kick-Off Wizard — complete flow + step validation + abandon confirmation
- Pipeline Monitoring — status transitions + real-time log display
- Results Review — filter, sort, export
---
Quality Gate Summary
# Type check
tsc --noEmit
# Lint (includes react, hooks, a11y)
eslint src/
# Format
prettier --check src/
# Unit + component tests
vitest run
# Coverage
vitest run --coverage
# End-to-end tests
playwright test
# Storybook build (catches story compilation errors)
storybook build --quietAll gates must pass before a PR is raised.
UX Journey Implementation Patterns
Document Upload: Implementation
File Selection Component
interface DropZoneProps {
readonly acceptedTypes: string[]; // e.g. ['.pdf', '.csv', 'application/json']
readonly maxFileSizeBytes: number;
readonly maxFileCount?: number;
readonly onFilesSelected: (files: File[]) => void;
readonly disabled?: boolean;
}
function DropZone({ acceptedTypes, maxFileSizeBytes, onFilesSelected, disabled }: DropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleDrop = useCallback((event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
const files = Array.from(event.dataTransfer.files);
onFilesSelected(files);
}, [onFilesSelected]);
return (
<div
role="button"
aria-label="Drop files here or click to browse"
tabIndex={0}
className={`${styles.dropZone} ${isDragOver ? styles.dragOver : ''}`}
onDrop={handleDrop}
onDragOver={e => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onClick={() => inputRef.current?.click()}
onKeyDown={e => e.key === 'Enter' && inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple
accept={acceptedTypes.join(',')}
className={styles.hiddenInput}
onChange={e => onFilesSelected(Array.from(e.target.files ?? []))}
aria-hidden="true"
tabIndex={-1}
/>
<UploadIcon aria-hidden="true" />
<p>Drop files here or <span className={styles.browseLink}>browse</span></p>
<p className={styles.hint}>
Accepted: {acceptedTypes.join(', ')} · Max {formatBytes(maxFileSizeBytes)} per file
</p>
</div>
);
}File Validation Hook
interface FileValidationOptions {
readonly acceptedTypes: string[];
readonly maxFileSizeBytes: number;
readonly maxFileCount?: number;
}
interface ValidatedFile {
readonly file: File;
readonly error: string | null;
}
function validateFiles(files: File[], options: FileValidationOptions): ValidatedFile[] {
return files.map(file => ({
file,
error: getFileError(file, options),
}));
}
function getFileError(file: File, options: FileValidationOptions): string | null {
if (!isAcceptedType(file, options.acceptedTypes)) {
return `${file.name}: file type not accepted`;
}
if (file.size > options.maxFileSizeBytes) {
return `${file.name}: exceeds ${formatBytes(options.maxFileSizeBytes)} limit`;
}
return null;
}Upload State Machine
Use a discriminated union to model upload state — never a bag of boolean flags:
type UploadState =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'ready'; files: ValidatedFile[] }
| { status: 'uploading'; progress: Record<string, number> }
| { status: 'complete'; results: UploadResult[] }
| { status: 'partial'; results: UploadResult[] }
| { status: 'failed'; error: string };---
Wizard / Multi-Step Form: Implementation
Wizard State Management
interface WizardState<TData> {
readonly currentStep: number;
readonly totalSteps: number;
readonly data: Partial<TData>;
readonly stepValidity: Record<number, boolean>;
}
function useWizard<TData>(totalSteps: number) {
const [state, setState] = useState<WizardState<TData>>({
currentStep: 0,
totalSteps,
data: {},
stepValidity: {},
});
const goToNext = useCallback(() => {
setState(prev => ({
...prev,
currentStep: Math.min(prev.currentStep + 1, prev.totalSteps - 1),
}));
}, []);
const goToPrevious = useCallback(() => {
setState(prev => ({
...prev,
currentStep: Math.max(prev.currentStep - 1, 0),
}));
}, []);
const updateStepData = useCallback((stepData: Partial<TData>) => {
setState(prev => ({ ...prev, data: { ...prev.data, ...stepData } }));
}, []);
const markStepValid = useCallback((step: number, valid: boolean) => {
setState(prev => ({
...prev,
stepValidity: { ...prev.stepValidity, [step]: valid },
}));
}, []);
return { state, goToNext, goToPrevious, updateStepData, markStepValid } as const;
}Step Indicator Component
interface StepIndicatorProps {
readonly currentStep: number;
readonly totalSteps: number;
readonly stepLabels: string[];
}
function StepIndicator({ currentStep, totalSteps, stepLabels }: StepIndicatorProps) {
return (
<nav aria-label="Progress">
<ol className={styles.steps}>
{stepLabels.map((label, index) => (
<li
key={label}
className={styles.step}
aria-current={index === currentStep ? 'step' : undefined}
>
<span className={getStepClass(index, currentStep)} aria-hidden="true">
{index + 1}
</span>
<span className={styles.stepLabel}>{label}</span>
</li>
))}
</ol>
<p className={styles.srOnly}>
Step {currentStep + 1} of {totalSteps}: {stepLabels[currentStep]}
</p>
</nav>
);
}Form Validation (react-hook-form)
// Per-step validation using react-hook-form
function PipelineConfigStep({ onComplete }: { onComplete: (data: ConfigData) => void }) {
const { register, handleSubmit, formState: { errors } } = useForm<ConfigData>({
mode: 'onBlur', // Validate on blur for better UX than onChange
});
return (
<form onSubmit={handleSubmit(onComplete)} noValidate>
<FormField
label="Pipeline name"
error={errors.name?.message}
required
>
<Input
{...register('name', {
required: 'Pipeline name is required',
maxLength: { value: 100, message: 'Name must be 100 characters or fewer' },
})}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
</FormField>
<WizardNavigation canProceed={!Object.keys(errors).length} />
</form>
);
}---
Pipeline Monitoring Dashboard: Implementation
Status Polling Hook
function usePipelineStatus(pipelineId: string) {
return useQuery({
queryKey: ['pipeline', pipelineId, 'status'],
queryFn: () => fetchPipelineStatus(pipelineId),
refetchInterval: query => {
// Poll only while pipeline is running; stop when terminal state reached
const status = query.state.data?.status;
if (status === 'running' || status === 'queued') return 2000;
return false;
},
staleTime: 0,
});
}Real-Time Log Stream Hook
function usePipelineLogStream(pipelineId: string, maxLines = 1000) {
const [lines, setLines] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const ws = new WebSocket(`/api/pipelines/${pipelineId}/logs`);
wsRef.current = ws;
ws.onmessage = event => {
const newLine: LogLine = JSON.parse(event.data as string);
setLines(prev => {
const updated = [...prev, newLine];
// Trim to max to prevent memory growth
return updated.length > maxLines ? updated.slice(-maxLines) : updated;
});
};
return () => ws.close();
}, [pipelineId, maxLines]);
return lines;
}Stage Progress Timeline
function StageProgressTimeline({ stages }: { stages: PipelineStage[] }) {
return (
<ol className={styles.timeline} aria-label="Pipeline stages">
{stages.map(stage => (
<li key={stage.id} className={styles.stageItem}>
<StageCard
name={stage.name}
status={stage.status}
durationMs={stage.durationMs}
recordCount={stage.recordCount}
/>
</li>
))}
</ol>
);
}---
Results Dashboard: Implementation
Filterable Data Table
function useResultsFilter<T>(data: T[], filterFn: (item: T, query: string) => boolean) {
const [query, setQuery] = useState('');
const filteredData = useMemo(
() => (query ? data.filter(item => filterFn(item, query)) : data),
[data, query, filterFn],
);
return { filteredData, query, setQuery } as const;
}Empty State Pattern
Always design the empty state:
function ResultsTable({ results }: { results: ProcessingResult[] }) {
if (results.length === 0) {
return (
<EmptyState
icon={<NoResultsIcon aria-hidden="true" />}
title="No results found"
description="The pipeline completed but produced no output records."
action={<Button onClick={handleRetry}>Re-run pipeline</Button>}
/>
);
}
return <DataTable data={results} />;
}Progressive Disclosure Pattern
Show summary first; reveal detail on demand:
function ResultsSummary({ summary, onViewDetails }: ResultsSummaryProps) {
return (
<section aria-label="Processing summary">
<MetricCard label="Records processed" value={summary.totalRecords} />
<MetricCard label="Errors" value={summary.errorCount} intent={summary.errorCount > 0 ? 'danger' : 'success'} />
<MetricCard label="Processing time" value={formatDuration(summary.durationMs)} />
<Button variant="secondary" onClick={onViewDetails}>
View detailed results
</Button>
</section>
);
}---
Accessibility Rules for Journey Components
| Pattern | Implementation |
|---|---|
| Wizard progress | <nav aria-label="Progress"> with aria-current="step" on active step |
| File upload area | role="button", tabIndex={0}, keyboard handler for Enter and Space |
| Status updates | aria-live="polite" region for non-critical updates; aria-live="assertive" for errors |
| Loading states | aria-busy="true" on the container; loading text for screen readers |
| Form errors | aria-describedby linking input to its error message; role="alert" on error summary |
| Dynamic content | Focus management on route/step change — move focus to the new heading |