frontend-engineering
Build and maintain web frontends — component architecture, state management,
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/frontend-engineering
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
git clone https://github.com/magnus919/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
README
Frontend Engineering
Frontend engineering methodology — component architecture, state management, API integration, responsive layout, client-side performance, and frontend testing patterns. Framework agnostic, focused on web frontend implementation.
Why Install This Skill
Your agent applies proven component architecture, state management, and performance patterns instead of reinventing frontend structure each time. Fillable templates capture component/state design and performance budgets as reviewable records, and the bundled bundle-budget checker enforces performance budgets in CI.
What You Get
| Directory | Purpose |
|---|---|
SKILL.md |
Core methodology, trigger conditions, reference index |
references/ |
Deep-dive reference files loaded on demand |
templates/ |
Fillable records: component/state design record, performance budget |
scripts/ |
bundle-budget-checker.py — checks bundle size reports against total and per-chunk budgets |
evals/ |
Output-quality eval manifest for the skill's methodology cases |
Triggers
Building UI components, choosing state management approaches, integrating APIs, optimizing Core Web Vitals, or setting up responsive layouts.
Requirements
Platform-agnostic. Framework-agnostic patterns applicable to React, Vue, Svelte, or vanilla JS. The bundled script needs only Python 3 (standard library).
Quick Start
Check a bundle size report against total and per-chunk budgets before merging a change:
python3 frontend-engineering/scripts/bundle-budget-checker.py dist/bundle-report.json --total 500KB --chunk 120KB
The report maps chunk names to sizes (or a {"chunks": [...]} list from your bundler's analyzer). The script prints each chunk, the budget, and OK/OVER status, and exits 1 when any budget is exceeded — so it can gate CI. Add --json for machine-readable output.
Load SKILL.md for the methodology overview and reference table, then load specific references as needed for the task at hand.
Skill manifest
Frontend Engineering Methodology
Frontend engineering is the craft of building the user-facing layer of applications — components, state management, API integration, responsive layout, and client-side performance. This methodology bridges UX design (user journeys, wireframes, accessibility standards) and quality validation (qa-methodology).
The Frontend Engineer's Domain
| You own | You don't own |
|---|---|
| Component implementation — UI component composition, props/state interfaces, rendering patterns, lifecycle | User journeys, wireframes, accessibility standards, interaction design — that's the product-design-and-ux |
| State management — client-side state architecture, data fetching patterns, caching, optimistic updates | API contract design — that's the api-design-and-evolution |
| API integration — frontend-to-backend data flow, auth flows (OAuth, JWT), real-time updates | Test strategy and automation — that's the QA-engineer |
| Responsive design implementation — layout systems, breakpoints, cross-device testing | Visual identity and brand guidelines — that's the brand-designer |
| Client-side performance — bundle optimization, lazy loading, Core Web Vitals, render optimization | Editorial content and copy — owned by the content and marketing team, outside this skill's scope |
| Frontend testing — component tests, integration tests, visual regression, accessibility tests | Code review and quality gates — that's the qa-methodology |
| E2E test scenarios and user-flow coverage for frontend features | Operating the browser test tool (Playwright) — authoring/running specs, selectors, network mocking, scraping — route to playwright |
| Mobile app implementation (iOS/Android/Flutter/React Native) | Mobile platform work — scaffolding, builds and code signing, device/emulator testing, store submission, mobile lifecycle — route to mobile-development; this skill owns web frontends |
| Build tooling — bundler config, TypeScript config, linting, formatting, dev environment | CI/CD pipeline infrastructure — that's the platform-engineer |
For React component and hooks work, load react; for Vite config, modes, plugins, and builds, load vite. This skill remains the owner of framework-neutral architecture and frontend strategy.
Reference Files
| Reference | When to load |
|---|---|
references/component-architecture.md |
Designing component trees — composition patterns, props/state interfaces, lifecycle, accessibility fundamentals |
references/state-management.md |
Choosing and implementing state management — client vs server state, data fetching, caching, optimistic updates |
references/api-integration.md |
Connecting frontend to backend — API client design, auth token flow, error handling in the UI, real-time subscriptions |
references/responsive-layout-testing.md |
Implementing responsive designs (layout system selection — Grid vs Flexbox vs Container Queries, breakpoint strategies, cross-device testing methodology) and testing frontend code (component testing with Testing Library, integration testing with Playwright/Cypress, visual regression, accessibility testing with axe-core and Lighthouse CI, test data management) |
references/performance.md |
Optimizing client-side performance — Core Web Vitals, bundle analysis, code splitting, render optimization |
Templates
| Template | When to Use |
|---|---|
templates/component-state-design-record.md |
Designing a component tree and state ownership for a feature — decomposition, state scoping, data fetching, and error/loading UX |
templates/performance-budget.md |
Defining performance targets — bundle byte budgets, Core Web Vitals budgets, measurement setup, and CI enforcement |
Scripts
| Script | When to Use |
|---|---|
scripts/bundle-budget-checker.py |
Checking a bundle size report against total and per-chunk budgets; fails (exit 1) when a budget is exceeded, so CI can block performance regressions |
Core Principles
Components are the unit of composition, not pages — Design and build components as reusable, composable units. Pages are assembled from components, not built as monoliths. A well-designed component can be reused in contexts its creator never imagined.
Co-locate state with the components that need it — Not every piece of state belongs in a global store. Local state stays local. Server state is fetched and cached. Only truly shared application state belongs in a global context.
Design for every state, not just the happy path — Every data-dependent component has at least four states: loading, empty, error, and success. Designing for all four is not a nicety — it creates a resilient user experience.
Accessibility is not a feature, it's a requirement — Keyboard navigation, screen reader support, color contrast, and focus management are not enhancements. They are part of the implementation contract.
Performance is a UX concern — Every millisecond of load time, every layout shift, every janky interaction erodes user trust. Performance budgeting, bundle analysis, and render optimization are part of frontend engineering, not an afterthought.
Files (agent-skills)
-
evals
-
evals.json 10.4 KB
{ "schema_version": 1, "skill_name": "frontend-engineering", "evals": [ { "id": "component-state-design", "prompt": "I am building a checkout flow with a cart summary, shipping address form, payment method selector, and order confirmation. The whole flow currently lives in one giant component with a dozen useState hooks and props threaded through five levels. Redesign the component structure and the state ownership for this flow.", "expected_output": "A component decomposition that breaks the checkout flow into focused, composable components — CartSummary, ShippingAddressForm, PaymentMethodSelector, OrderConfirmation — each with a narrow props interface and its own local state where the state is only used there. The design co-locates state with the components that need it: form field state stays local to each form, the cart contents and order status are server state fetched and cached, and only genuinely shared state (for example the active step or the selected payment method used across siblings) lives in a shared context or store. Every data-dependent component defines loading, empty, error, and success states, and props stay flat and explicit so components remain reusable outside the checkout flow.", "assertions": [ "The response decomposes the flow into focused components with narrow, explicit props interfaces", "The response co-locates local state with the components that need it and separates server state from client state", "The response limits shared/global state to what multiple components genuinely need", "The response designs all four states (loading, empty, error, success) for data-dependent components", "The response keeps components reusable by avoiding deep prop drilling and context sprawl" ] }, { "id": "state-management-selection", "prompt": "Our team is about to pick a state management approach for a dashboard app: it fetches a lot of server data (users, reports, settings), has some shared UI state (open sidebar, active filters), and lots of form state. We are debating a global store, server-cache libraries, and just component state. What should we choose and where should each kind of state live?", "expected_output": "A state management decision that separates the three kinds of state instead of picking one tool for everything. Server state (users, reports, settings) belongs in a server-cache layer that owns fetching, caching, deduplication, invalidation, and background refetch rather than being copied into a global store. Shared UI state (sidebar, active filters) lives in the smallest scope that covers its consumers — a component-level context or a lightweight store slice. Form and ephemeral state stays local to components. The response explains the tradeoff: a global store adds complexity and becomes a dumping ground when used for server data, while server-cache libraries handle the hard parts (retries, staleness, mutation cache updates) that hand-rolled fetching duplicates. It also covers how the choice scales as the app grows and what migration path looks like if the team already has a store.", "assertions": [ "The response separates server state, shared UI state, and local state instead of choosing one tool for all three", "The response routes server data through a server-cache layer with caching, deduplication, and invalidation", "The response keeps shared UI state in the smallest scope that covers its consumers", "The response keeps form and ephemeral state local to components", "The response explains the tradeoffs and a migration path from an existing global store" ] }, { "id": "api-integration-design", "prompt": "Our React app needs to talk to a REST API that requires a bearer token, returns paged collections, and occasionally returns 429s. Right now every component calls fetch directly and each screen re-implements token handling and error display. Design the API integration layer for this frontend.", "expected_output": "An API integration layer with a single API client module that owns the base URL, request serialization, auth token attachment and refresh-on-401 handling, and a standard error shape the UI can render. The layer exposes typed functions per domain (listUsers, fetchReport) that components call instead of raw fetch, handles retry with backoff for 429 responses, and normalizes errors into a common structure with a user-facing message plus a machine-readable code. Components receive data through a data-fetching layer (query hook or cache) so loading, error, and success states are handled once instead of per component. The design covers pagination: the client exposes cursor or page helpers so infinite scroll and paginated tables do not reimplement slicing, and auth flows (OAuth/JWT refresh) are handled in the client rather than in components.", "assertions": [ "The response centralizes HTTP in one API client module that owns base URL, serialization, and auth token handling", "The response handles 401-triggered token refresh and retry with backoff for 429 responses in the client layer", "The response normalizes errors into a common shape with a user-facing message and a machine-readable code", "The response routes data through a data-fetching layer so loading/error/success states are handled once", "The response covers pagination helpers and keeps auth flows out of individual components" ] }, { "id": "data-fetching-loading-error-empty", "prompt": "I need a user profile page that fetches a user by id from /users/{id} and shows their posts. The API can return 404 for a missing user, 500 on server trouble, and an empty list of posts is valid. Design the data-dependent component states for this page.", "expected_output": "A component design that treats loading, error, empty, and success as first-class states. Loading renders a skeleton or spinner with an accessible busy indicator (aria-busy) rather than a blank screen. Error handling distinguishes the 404 case — a clear 'user not found' message with a link back to the directory — from 500s, which show a retry affordance and a user-friendly message while logging the technical detail to the monitoring tool. Empty posts render a purpose-built empty state (an illustration plus a call to action), not an error, because an empty list is a valid success. The success state renders the profile with the posts. The response also covers refetching after a failed load without losing the user's place, and cancelling or ignoring stale responses when the user navigates away.", "assertions": [ "The response defines four distinct states: loading, error, empty, and success", "The response renders an accessible loading state instead of a blank screen", "The response distinguishes 404 from 500 handling with different user-facing outcomes", "The response treats an empty list as a valid success with its own empty-state design", "The response covers retry without losing user context and ignoring stale responses after navigation" ] }, { "id": "performance-review", "prompt": "Our marketing site loads slowly: the initial bundle is 1.4 MB, images are not sized, and Lighthouse shows LCP 4.2 s and CLS 0.35. Walk me through reviewing and fixing the frontend performance of this site.", "expected_output": "A performance review structured around measuring before optimizing: run Lighthouse and collect Core Web Vitals (LCP, CLS, INP, TBT) with field data to confirm the regression source. The fixes target the named problems: split the bundle by route with code splitting and lazy loading so the initial bundle only contains above-the-fold code, remove or defer heavy dependencies, serve properly sized and compressed images with explicit dimensions to eliminate layout shift, preload the LCP element, and use modern formats (AVIF/WebP). CLS is fixed by reserving space for images, ads, and fonts (font-display swap, size-adjust) and avoiding injecting content above already-rendered content. The response prioritizes by impact: the biggest wins first, re-measure after each change, and add a performance budget so regressions are caught in CI.", "assertions": [ "The response starts by measuring with Lighthouse and field Core Web Vitals before changing anything", "The response reduces the initial bundle via route-based code splitting and lazy loading", "The response fixes CLS by reserving space for images and fonts and avoiding injected layout shift", "The response addresses image sizing, compression, and modern formats for LCP", "The response prioritizes fixes by impact and adds a performance budget enforced in CI" ] }, { "id": "performance-budget-implementation", "prompt": "We want to stop our app from getting slower release after release. I need to set up a performance budget: what metrics should it cover, how do we measure it, and how do we enforce it so a regression fails the build? We ship our JS bundle report as JSON.", "expected_output": "A performance-budget plan covering the three dimensions that matter: a byte budget for the initial JS/CSS bundle (for example 250 KB gzipped of route-level code, enforced per route), timing budgets for Core Web Vitals (LCP under 2.5 s, CLS under 0.1, INP under 200 ms) measured by Lighthouse in CI, and a request/asset budget for third-party scripts. The plan measures the bundle from the build output — running the bundle-budget-checker script on the bundle report with --total and --chunk budgets so an oversized chunk fails the build — and measures vitals with Lighthouse in a CI job that fails on budget breach. The response covers the workflow: budgets live in a committed config, alerts go to the team when a PR exceeds them, and every change is compared against the same baseline so the budget is meaningful.", "assertions": [ "The response defines byte budgets for route-level JS/CSS and timing budgets for Core Web Vitals", "The response measures the bundle from build output and enforces it in CI", "The response mentions running the bundle-budget-checker script on the bundle report with total and chunk budgets", "The response covers third-party script and request budgets", "The response commits budgets as config and compares every change against the same baseline" ] } ] }
-
-
references
-
api-integration.md 1.6 KB
# API Integration ## API Client Design | Layer | Responsibility | |-------|---------------| | Client instance | Base URL, default headers, timeout, interceptors | | Request interceptor | Auth token injection, request logging, request ID | | Response interceptor | Token refresh on 401, error normalization, response logging | | Service module | Typed API methods per domain, request/response transformation | | Hook layer | Data fetching hooks with loading/error/empty states | ## Auth Token Flow ``` Login → Store token → Attach to requests → Detect expiry → Refresh → Retry ↓ Re-login on refresh failure ``` | Storage location | Pros | Cons | |-----------------|------|------| | HTTP-only cookie | Secure against XSS | CSRF vulnerability, harder for SPA to read | | Memory (variable) | Most secure, not persisted | Lost on page refresh | | localStorage | Survives refresh, simple | Accessible by any JS on the page | | Session storage | Survives refresh in same tab | Cleared on tab close | ## Real-Time Updates | Protocol | When to use | Connection management | |----------|-------------|---------------------| | WebSocket | Bidirectional, low-latency | Reconnect with backoff, heartbeat, fallback to polling | | SSE (Server-Sent Events) | Server-to-client only, simpler than WebSocket | Automatic reconnection, event ID for resume | | Short polling | Simple, no server push support | Fixed interval, wasteful when idle | | Long polling | When WebSocket/SSE unavailable | Persistent connection, complex timeout handling | -
component-architecture.md 1.8 KB
# Component Architecture ## Composition Patterns | Pattern | When to use | Example | |---------|-------------|---------| | Atomic design | Design systems with clear hierarchy | `Button → FormField → AddressForm → CheckoutPage` | | Compound components | Related components that share implicit state | `Select.Trigger`, `Select.Options`, `Select.Option` | | Render props | Maximum flexibility in component behavior | Data provider that delegates rendering to consumer | | Controlled vs uncontrolled | Form inputs, external state management | Controlled: state lives in parent. Uncontrolled: state lives in component | | Higher-order components | Cross-cutting concerns (auth, logging) | `withAuth(Component)`, `withAnalytics(Component)` | ## Props and State Interface Design | Aspect | Guideline | |--------|-----------| | Props should be minimal | Pass only what the component needs. Avoid prop drilling with context. | | Defaults for optional props | Every optional prop has a sensible default. | | Boolean props are named as questions | `isLoading`, `hasError`, `isDisabled`, `canSubmit` | | Callback props describe the event | `onClick`, `onSubmit`, `onChange`, `onClose` | | Avoid overloaded props | A prop should do one thing. `variant="primary|secondary|danger"`, not `mode="view|edit|admin"` | ## Accessibility Fundamentals Every component must support: - **Keyboard navigation** — All interactive elements reachable and operable via keyboard (Tab, Enter, Escape, Arrow keys) - **Focus management** — Visible focus indicators, logical tab order, focus trapping in modals - **Screen reader support** — ARIA labels, roles, live regions, landmarks - **Color contrast** — Text meets WCAG AA (4.5:1 for normal text, 3:1 for large text) - **Reduced motion** — Respect `prefers-reduced-motion` for animations and transitions -
performance.md 1.4 KB
# Client-Side Performance ## Core Web Vitals Targets | Metric | Good | Needs improvement | Poor | |--------|------|-------------------|------| | LCP (Largest Contentful Paint) | ≤ 2.5s | 2.5s - 4.0s | > 4.0s | | FID (First Input Delay) / INP | ≤ 100ms | 100ms - 300ms | > 300ms | | CLS (Cumulative Layout Shift) | ≤ 0.1 | 0.1 - 0.25 | > 0.25 | ## Bundle Optimization | Technique | Impact | Effort | |-----------|--------|--------| | Code splitting by route | High | Low (built-in with most frameworks) | | Dynamic imports for heavy components | Medium | Low | | Tree shaking unused exports | Medium | Low (enabled by default in bundlers) | | Import cost awareness | Medium | Medium (lint rules, CI checks) | | Image optimization (format, sizing, lazy loading) | High | Medium | | Dependency audit (remove unused, find lighter alternatives) | Medium | High | ## Render Optimization | Pattern | When to use | Mechanism | |---------|-------------|-----------| | Memoization | Pure components that re-render often | `React.memo`, `useMemo`, `useCallback` | | Virtualization | Long lists (1000+ items) | `react-window`, `react-virtuoso` | | Debouncing | High-frequency events (search input, scroll) | Debounce by 300-500ms | | Throttling | Rate-limited updates (resize, scroll position) | Throttle by 100-200ms | | Progressive hydration | Heavy interactive content below the fold | Lazy hydrate on visibility | -
responsive-layout-testing.md 31.6 KB
# Frontend Engineering Methodology Reference > Comprehensive reference covering responsive layout systems, breakpoint strategy, > cross-device testing, and frontend testing patterns (component, integration, > visual regression, accessibility, and test data management). > > Compiled: June 2026 --- ## Table of Contents 1. [Responsive Layout Systems](#1-responsive-layout-systems) 2. [Breakpoint Strategy](#2-breakpoint-strategy) 3. [Cross-Device Testing Methodology](#3-cross-device-testing-methodology) 4. [Frontend Testing Overview](#4-frontend-testing-overview) 5. [Component Testing](#5-component-testing) 6. [Integration Testing](#6-integration-testing) 7. [Visual Regression Testing](#7-visual-regression-testing) 8. [Accessibility Testing](#8-accessibility-testing) 9. [Test Data Management](#9-test-data-management) --- ## 1. Responsive Layout Systems ### Core Philosophy > **"CSS Grid is for layout; Flexbox is for alignment."** > > Use the right tool for the dimensionality of the problem. Modern CSS provides three layout primitives, each suited to different concerns. The modern approach combines all three rather than choosing one. ### 1.1 CSS Grid — Two-Dimensional Layout **Best for:** Page-level structure, complex multi-axis layouts, precise spatial control, and layouts where rows AND columns matter simultaneously. | Use Case | Example | |---|---| | Full-page templates | Header, sidebar, main, footer regions | | Card grids | Gallery, dashboard, e-commerce listings | | Overlapping elements | Hero sections, magazine layouts | | Gap-native layouts | `gap` property avoids margin hacks | | Fraction-based sizing | `fr` units, `minmax()`, `auto-fill`/`auto-fit` | **Key patterns:** ```css /* Responsive grid without media queries */ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 1rem; } /* Named grid areas for page layout */ .page { display: grid; grid-template-areas: "header header" "sidebar main" "footer footer"; grid-template-columns: 1fr 3fr; } ``` **When to choose Grid over Flexbox:** - You need control over both rows and columns simultaneously - You want explicit placement (grid-column / grid-row) - You have a predefined layout structure (layout-first design) - You need overlapping elements without hacks - You're building page-level templates ### 1.2 Flexbox — One-Dimensional Alignment **Best for:** Component-level layouts, linear sequences, dynamic content flow, centering, and distributing items along a single axis. | Use Case | Example | |---|---| | Navigation bars | Horizontal link lists, toolbars | | Card internal layout | Row of label + value, button groups | | Centering | Vertically/horizontally centering content | | Dynamic wrapping | Tags, chips, badge lists | | Flexible spacing | `justify-content: space-between` on footers | | Reordering | `order` property for responsive reflow | **Key patterns:** ```css /* Centering */ .container { display: flex; align-items: center; justify-content: center; } /* Responsive wrapping */ .tag-list { display: flex; flex-wrap: wrap; gap: 0.5rem; } /* Holy grail of spacing */ .toolbar { display: flex; justify-content: space-between; align-items: center; } ``` **When to choose Flexbox over Grid:** - Content flows in one direction (row OR column, not both) - Item sizes are unknown and should determine layout (content-first design) - You need simple centering or alignment - You're building reusable UI components (buttons, navs, toolbars) - Items need to wrap to the next line naturally ### 1.3 Container Queries — Component-Level Responsiveness **Best for:** Reusable components that must adapt to their parent container's size rather than the viewport. The paradigm shift from viewport-centric to container-centric design. ```css /* Establish containment context */ .card-grid { container-type: inline-size; container-name: cards; } /* Query against the container */ @container cards (width >= 30rem) { .card { display: grid; grid-template: "media body" auto / 2fr 3fr; } .card__media { block-size: 100%; object-fit: cover; } } @container cards (width >= 60rem) { .card { grid-template: "media body aside" auto / 1fr 2fr 1fr; } } ``` **Container units:** | Unit | Meaning | |---|---| | `cqw` | 1% of container width | | `cqh` | 1% of container height | | `cqi` | 1% of container inline size | | `cqb` | 1% of container block size | | `cqmin` | Smaller of `cqi` and `cqb` | | `cqmax` | Larger of `cqi` and `cqb` | **Style queries** (CSS 2025): Conditionally style based on a container's custom properties or state. ```css @container style(--density: compact) { .card { padding: 0.75rem; gap: 0.5rem; } } @container style(--theme: surface) { .card { background: #fff; color: #111; } } ``` **When to use container queries:** - The same component appears in multiple contexts (sidebar vs. main content) - You want truly reusable design-system components - Component breakpoints differ from page-level breakpoints - You need to scale typography relative to the component, not the viewport **Progressive enhancement pattern:** ```css /* Base — works everywhere */ .card { display: block; } /* Enhancement — only if supported */ @supports (container-type: inline-size) { .card-wrapper { container-type: inline-size; } @container (width >= 25rem) { .card { display: grid; grid-template-columns: 1fr 2fr; } } } ``` ### 1.4 Decision Matrix: Grid vs. Flexbox vs. Container Queries | Criterion | CSS Grid | Flexbox | Container Queries | |---|---|---|---| | **Dimensionality** | 2D (rows + cols) | 1D (row OR col) | N/A (context only) | | **Primary use** | Page layout | Component alignment | Component adaptation | | **Content vs. layout driven** | Layout-first | Content-first | Container-driven | | **Gap support** | Native `gap` | Native `gap` | Via host layout | | **Overlap support** | Native (grid placement) | Not designed for | Not applicable | | **Reordering** | Via placement | Via `order` | N/A | | **Responsive technique** | `auto-fill`/`minmax()` + MQ | `flex-wrap` + MQ | `@container` queries | | **Browser support** | Universal | Universal | ~90%+ (2026) | | **Reusable components** | Possible, but rigid | Good | Best fit | ### 1.5 Modern Fluid Layout Toolkit (2025+) Beyond the three primitives, modern CSS offers fluid sizing tools that reduce or eliminate media queries: ```css /* Fluid typography */ h1 { font-size: clamp(1.5rem, 2.5vw + 1rem, 3rem); } /* Fluid grid columns */ .grid { grid-template-columns: repeat(auto-fill, minmax(clamp(12rem, 30%, 24rem), 1fr)); } /* Intrinsic sizing with aspect-ratio */ .card { aspect-ratio: 16 / 9; } /* Logical properties for RTL support */ .card { margin-inline: 1rem; padding-block: 2rem; } ``` --- ## 2. Breakpoint Strategy ### 2.1 Device-Agnostic vs. Content-Driven **The industry consensus in 2025-2026 is: content-driven breakpoints, not device-based presets.** | Approach | Description | Verdict | |---|---|---| | **Device-agnostic** | Breakpoints at key widths where content breaks (e.g., 480px, 768px, 1024px) | Legacy best practice — better than fixed device targeting, but still viewport-centric | | **Content-driven** | Breakpoints determined by the content itself — resize until it looks wrong, then add a breakpoint | Modern best practice | | **Container-driven** | Breakpoints live on components via `@container`, not the viewport | Cutting edge (2025+) | ### 2.2 The Problem With Fixed Breakpoints Traditional breakpoint strategy used device classes: ```css /* Avoid: device-specific */ @media (max-width: 575px) { /* phones */ } @media (min-width: 576px) { /* tablets */ } @media (min-width: 992px) { /* laptops */ } @media (min-width: 1200px) { /* desktops */ } ``` This fails because: - New devices appear constantly (foldables, ultra-wides, 2-in-1s) - The same component might need different breakpoints in different contexts - It couples layout logic to arbitrary screen widths that may not match actual content needs ### 2.3 Content-Driven Breakpoint Strategy **Methodology:** 1. **Design in the browser** — resize gradually; stop every time the layout breaks 2. **Add a breakpoint at each breaking point**, naming it after what breaks, not the pixel value 3. **Use `rem` not `px`** for breakpoints — respects user font-size preferences 4. **Prefer fluid techniques first** (clamp, minmax, auto-fill) before adding media queries ```css /* Good: content-driven breakpoints in rem */ /* Breakpoints at 30rem, 48rem, 64rem */ /* Prefer: fluid techniques before media queries */ .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); } /* Only add media queries when fluid isn't enough */ @media (width >= 64rem) { .card-grid { grid-template-columns: repeat(3, 1fr); } } ``` ### 2.4 Recommended Breakpoint Ranges Not fixed values, but ranges where content typically breaks: | Range (approx) | Common name | Behaviour | |---|---|---| | < 30rem (~480px) | Single-column | Stack everything | | 30-48rem (~480-768px) | Narrow | 2-column grids possible | | 48-64rem (~768-1024px) | Medium | 3-column layouts, sidebars | | 64-90rem (~1024-1440px) | Wide | Full layouts, multi-column | | > 90rem (~1440px) | Extra-wide | Max-width constraints, whitespace | > **Key insight:** These are guidelines, not dogma. Let YOUR content determine > the exact values. A data table might need 50rem; a long-form article might > need only 35rem. ### 2.5 Modern Media Query Syntax CSS Media Queries Level 4+ introduced range syntax (widely supported in 2025): ```css /* Old syntax */ @media (min-width: 768px) and (max-width: 1024px) { } /* New range syntax — cleaner */ @media (768px <= width <= 1024px) { } @media (width >= 48rem) { } @media (width < 30rem) { } ``` ### 2.6 Preference Queries (Accessibility-Aware) Beyond size, modern responsive design queries user preferences: ```css /* Respect reduced motion */ @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; } } /* Respect reduced transparency */ @media (prefers-reduced-transparency: reduce) { .glass { background: solid; } } /* Respect dark mode */ @media (prefers-color-scheme: dark) { :root { --bg: #111; --text: #eee; } } /* Respect increased contrast */ @media (prefers-contrast: more) { .card { border: 2px solid; } } ``` --- ## 3. Cross-Device Testing Methodology ### 3.1 Test Pyramid for Responsive Design ``` /\ / \ Manual device testing / M \ (real hardware for critical paths) / a u \ / n a \ Device-emulation E2E / u l \ (Playwright emulation matrix) / a t e \ / l e s t \ /_______________\ Automated layout-safe checks (container queries, fluid validation) ``` ### 3.2 Device Emulation Matrix (Playwright/Cypress) Define your test matrix based on actual user analytics. Common strategy: ```javascript // Playwright config — device emulation const devices = [ { name: 'iPhone 15', width: 390, height: 844, deviceScaleFactor: 3 }, { name: 'Pixel 8', width: 412, height: 915, deviceScaleFactor: 2.625 }, { name: 'iPad Air', width: 820, height: 1180, deviceScaleFactor: 2 }, { name: 'Desktop 1440', width: 1440, height: 900, deviceScaleFactor: 1 }, { name: 'Desktop 1920', width: 1920, height: 1080, deviceScaleFactor: 1 }, ]; ``` ### 3.3 Responsive Testing Checklist | Check | Method | Tooling | |---|---|---| | Content doesn't overflow | Automated CSS assertion | Playwright `toHaveCSS` | | Touch targets >= 44px | Automated size check | Playwright bounding box | | No horizontal scrollbar | Visual regression | Percy / Chromatic | | Font size >= 16px (iOS zoom) | Emulation check | Safari/iOS device | | Tap targets don't overlap | Layout check | axe-core / manual | | All interactive elements work on touch | E2E test | Playwright touch emulation | | Viewport meta tag present | Lint check | Lighthouse | ### 3.4 Real Device Testing Strategy **Automated emulation covers ~80% of responsive bugs.** Real devices are needed for: 1. **Touch interactions** — hover states, drag, swipe, force touch 2. **Hardware-specific** — notch, dynamic island, camera cutouts, safe areas 3. **Performance** — real CPU/memory constraints, network throttling 4. **Rendering differences** — Safari vs. Chrome font rendering, sub-pixel differences **Practical approach:** - **CI/CD:** Emulation matrix (Playwright + devices) - **Pull request review:** Visual regression (Percy/Chromatic) - **Pre-release:** Real device cloud (BrowserStack / Sauce Labs / AWS Device Farm) - **Critical paths:** Physical devices owned by the team ### 3.5 Environment Simulation ```javascript // Playwright — responsive + environment simulation test('homepage on slow 3G', async ({ page }) => { await page.emulate({ viewport: { width: 390, height: 844 } }); await page.context().addInitScript(() => { // Simulate reduced motion window.matchMedia = (query) => ({ matches: query.includes('reduce-motion'), media: query, addListener: () => {}, removeListener: () => {}, }); }); await page.goto('/', { waitUntil: 'networkidle' }); // assertions... }); ``` --- ## 4. Frontend Testing Overview ### 4.1 The Testing Trophy (Modern Frontend) Replace the traditional "testing pyramid" with Kent C. Dodds' **Testing Trophy**, which better reflects frontend priorities: ``` /\ / \ Static analysis (TypeScript, ESLint) / \ Unit/Component tests (Vitest + Testing Library) / \ Integration tests (Playwright / Cypress) /________\ E2E tests (critical user journeys only) ``` **Static analysis** catches type errors and lint issues at compile time. **Component tests** verify isolated UI behaviour. **Integration tests** (the bulk of your test suite) verify features work together. **E2E tests** cover the most critical user journeys end-to-end. ### 4.2 Testing Matrix Summary | Layer | Tool | Scope | Speed | Flakiness | CI cost | |---|---|---|---|---|---| | Static | TypeScript, ESLint | Types, lint | Instant | Never | Free | | Component | Vitest + Testing Library | Individual components | Fast (ms) | Low | Cheap | | Integration | Playwright / Cypress | Features, page interactions | Medium (s) | Low-Med | Moderate | | Visual regression | Percy / Chromatic | Pixel-level UI | Medium (s) | Medium | Higher | | Accessibility | axe-core + Lighthouse | WCAG violations | Fast (ms-s) | Low | Cheap | | E2E critical | Playwright | Full user journeys | Slow (min) | Medium | Highest | --- ## 5. Component Testing ### 5.1 Vitest + React Testing Library **Standard setup (2025-2026):** ```javascript // vitest.config.js import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { globals: true, environment: 'jsdom', setupFiles: './src/test/setup.js', css: true, // process CSS imports }, }); ``` ```javascript // src/test/setup.js import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; afterEach(() => { cleanup(); }); ``` ### 5.2 Core Query Priority **Test as users experience the UI:** ``` 1. getByRole — Preferred for almost everything 2. getByLabelText — Form fields 3. getByPlaceholderText — Input hints 4. getByText — Non-interactive text 5. getByDisplayValue — Form values 6. getByAltText — Images 7. getByTitle — Tooltips 8. getByTestId — Last resort (data-testid) ``` ### 5.3 Component Test Patterns **Render + Interaction + Assert:** ```javascript import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Counter } from './Counter'; it('increments count when button clicked', async () => { const user = userEvent.setup(); render(<Counter />); await user.click(screen.getByRole('button', { name: /increment/i })); expect(screen.getByText('Count: 1')).toBeInTheDocument(); }); ``` **Test behaviour, not implementation:** ```javascript // Bad: testing internal state expect(counter.state.count).toBe(1); // Good: testing what the user sees expect(screen.getByText('Count: 1')).toBeInTheDocument(); ``` **Mock external dependencies:** ```javascript import axios from 'axios'; vi.mock('axios'); it('displays posts after fetch', async () => { const posts = [{ id: 1, title: 'Hello' }]; axios.get.mockResolvedValue({ data: posts }); render(<PostsList />); await waitFor(() => { expect(screen.getByText('Hello')).toBeInTheDocument(); }); }); ``` **Custom hook testing:** ```javascript import { renderHook, waitFor } from '@testing-library/react'; it('returns data from fetch', async () => { const { result } = renderHook(() => useFetch('/api/data')); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.data).toEqual({ id: 1 }); }); ``` ### 5.4 Best Practices (Component Testing) | Practice | Rationale | |---|---| | Test user-visible outcomes, not internals | Refactoring doesn't break tests | | One assertion per test (when practical) | Clear failure messages | | Use `userEvent`, not `fireEvent` | Realistic interaction simulation | | Mock API calls, not modules | Tests stay fast and focused | | Prefer `screen.` methods over destructured render | Keeps tests maintainable | | Always clean up DOM between tests | Prevents test pollution | | Use descriptive test names | `it('disables button while submitting')` | | Write the test for the component's contract, not its internals | Tests validate behaviour | --- ## 6. Integration Testing ### 6.1 Playwright vs. Cypress (2025-2026) | Dimension | Playwright | Cypress | |---|---|---| | **Browser support** | Chromium, Firefox, WebKit | Chromium, Firefox (limited), WebKit (beta) | | **Language** | JS/TS, Python, Java, .NET | JS/TS only | | **Architecture** | Browser protocol (CDP) — runs outside browser | In-browser — runs inside the browser | | **Multi-tab/window** | Native support | Limited | | **Network mocking** | Route interception | cy.intercept | | **Parallel execution** | Native, sharding | Dashboard required | | **Cross-origin iframes** | Full support | Limited | | **Mobile emulation** | Built-in device descriptors | cy.viewport only | | **API testing** | Same context as browser | cy.request | | **Community** | Larger momentum (91% satisfaction in State of JS 2025) | Mature, but satisfaction declined (72%) | | **CI integration** | Zero config | Dashboard or plugin | **Bottom line (2026):** Playwright has become the default choice for new projects due to broader browser support, multi-tab handling, and stronger momentum. Cypress remains viable for teams already invested in its ecosystem. ### 6.2 Playwright Integration Test Patterns **Page Object Model (POM) — recommended:** ```typescript // pages/LoginPage.ts export class LoginPage { constructor(private page: Page) {} async goto() { await this.page.goto('/login'); } async login(email: string, password: string) { await this.page.fill('[data-testid="email"]', email); await this.page.fill('[data-testid="password"]', password); await this.page.click('[data-testid="submit"]'); } async getErrorMessage() { return this.page.textContent('[data-testid="error"]'); } } ``` ```typescript // tests/login.spec.ts import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/LoginPage'; test('shows error on invalid credentials', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('bad@email.com', 'wrong'); await expect(loginPage.getErrorMessage()).toContain('Invalid credentials'); }); ``` **Responsive integration test with device emulation:** ```typescript test('mobile navigation is usable', async ({ page }) => { // Emulate mobile viewport await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); await page.click('[data-testid="hamburger"]'); await expect(page.locator('[data-testid="nav-menu"]')).toBeVisible(); // Touch targets meet minimum size (44x44 CSS pixels) const links = page.locator('nav a'); const count = await links.count(); for (let i = 0; i < count; i++) { const box = await links.nth(i).boundingBox(); expect(box?.width).toBeGreaterThanOrEqual(44); expect(box?.height).toBeGreaterThanOrEqual(44); } }); ``` **API mocking in integration tests:** ```typescript test('displays empty state when no results', async ({ page }) => { // Intercept API call and return empty results await page.route('**/api/search**', async route => { await route.fulfill({ json: { results: [] } }); }); await page.goto('/search'); await page.fill('[name="q"]', 'nonexistent'); await page.press('[name="q"]', 'Enter'); await expect(page.getByText('No results found')).toBeVisible(); }); ``` ### 6.3 Test Organization ``` tests/ e2e/ login.spec.ts checkout.spec.ts integration/ api/ search.spec.ts user.spec.ts features/ filters.spec.ts pagination.spec.ts visual/ homepage.spec.ts product-card.spec.ts accessibility/ homepage.a11y.spec.ts form.a11y.spec.ts ``` --- ## 7. Visual Regression Testing ### 7.1 Approaches | Approach | Tool | Pros | Cons | |---|---|---|---| | **Pixel-by-pixel screenshot diff** | Percy, Applitools, Chromatic | Catches every visual change | Baseline management, flakiness from animated content | | **DOM snapshot** | Jest/Vitest snapshots | Fast, no browser needed | Fragile, doesn't catch CSS-only changes | | **CSS-in-JS snapshot** | Storybook + Chromatic | Component-level, integrated with design system | Requires Storybook setup | | **Layout diff** | Playwright screenshot | Full-page, device-emulated | Slower, per-environment diffs | | **AI-assisted** | Percy AI, Applitools Eyes | Smart change detection, reduced false positives | Cost, vendor lock-in | ### 7.2 Percy (BrowserStack) ```javascript // Cypress + Percy cy.visit('/'); cy.percySnapshot('Homepage'); // Playwright + Percy import percySnapshot from '@percy/playwright'; test('homepage visual', async ({ page }) => { await page.goto('/'); await percySnapshot(page, 'Homepage'); }); ``` ### 7.3 Chromatic (Storybook) ```javascript // .github/workflows/chromatic.yml name: Chromatic on: push jobs: chromatic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - run: npm ci - uses: chromaui/action@v11 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} ``` ### 7.4 Playwright Native Visual Testing ```typescript import { test, expect } from '@playwright/test'; test('homepage matches snapshot', async ({ page }) => { await page.goto('/'); await expect(page).toHaveScreenshot('homepage.png', { maxDiffPixels: 100, fullPage: true, }); }); ``` ### 7.5 Visual Testing Best Practices | Practice | Detail | |---|---| | Mock dynamic content | Replace real data with fixtures for stable screenshots | | Freeze animations | Use `page.addStyleTag()` to disable CSS animations | | Isolate component states | Test loading, empty, error, and edge case states | | Set consistent viewport | Always snapshot from known viewport sizes | | Use CI diff thresholds | Allow configurable pixel tolerance to reduce flakiness | | Review diffs in PRs | Block merging on unapproved visual changes | | Rebase baselines intentionally | Not on every commit — only when changes are expected | --- ## 8. Accessibility Testing ### 8.1 Automation Coverage Automated accessibility testing catches roughly **30-40%** of WCAG violations. This covers the "low-hanging fruit" — common, detectable issues. Manual testing is still required for the remaining 60-70%. **What automation catches well:** - Missing alt text on images - Missing form labels - Insufficient colour contrast - Duplicate IDs - Missing ARIA attributes - Invalid ARIA usage - Missing lang attributes - Empty links / buttons **What requires manual testing:** - Logical reading order - Keyboard navigation flow - Screen reader announcements - Focus management - Meaningful alt text - Colour-only information transmission ### 8.2 axe-core Integration **In Vitest/unit tests (jest-axe):** ```javascript import { render } from '@testing-library/react'; import { axe, toHaveNoViolations } from 'jest-axe'; expect.extend(toHaveNoViolations); it('has no accessibility violations', async () => { const { container } = render(<Button>Click me</Button>); const results = await axe(container); expect(results).toHaveNoViolations(); }); ``` **In Playwright E2E tests:** ```typescript import AxeBuilder from '@axe-core/playwright'; test('homepage has no a11y violations', async ({ page }) => { await page.goto('/'); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); // Specific WCAG levels test('meets WCAG AA requirements', async ({ page }) => { await page.goto('/'); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) .analyze(); expect(results.violations).toEqual([]); }); // Targeted scan — specific element test('navigation menu is accessible', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: 'Menu' }).click(); const results = await new AxeBuilder({ page }) .include('#nav-flyout') .analyze(); expect(results.violations).toEqual([]); }); ``` ### 8.3 Lighthouse CI ```javascript // lighthouserc.json { "ci": { "collect": { "numberOfRuns": 3, "staticDistDir": "./build", "settings": { "onlyCategories": ["accessibility"] } }, "assert": { "assertions": { "categories:accessibility": ["error", { "minScore": 0.9 }] } }, "upload": { "target": "temporary-public-storage" } } } ``` ### 8.4 CI/CD Integration ```yaml # .github/workflows/accessibility.yml name: Accessibility on: [pull_request] jobs: a11y: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run build - run: npx playwright install - name: Run a11y tests run: npx playwright test --project=a11y ``` ### 8.5 Accessibility Testing Maturity Model | Level | What you do | Coverage | |---|---|---| | 1. None | No accessibility testing | 0% | | 2. Manual only | Occasional Lighthouse audits | 30-40% (sporadic) | | 3. Automated in CI | axe-core on every PR in Playwright tests | 30-40% (consistent) | | 4. Automated + enforcement | Lighthouse CI score gates | 30-40% + regression detection | | 5. Integrated into component tests | jest-axe on every component | 30-40% at component level | | 6. Full pipeline | Axe + Lighthouse + manual audits + screen reader | 30-40% automated + 60-70% manual | --- ## 9. Test Data Management ### 9.1 Data Generation Strategies | Strategy | Description | When to use | |---|---|---| | **Fixtures** | Static, pre-defined data in JSON/YAML files | Test data that doesn't change often | | **Factories** | Programmatic data generation with overrides | Many tests with slight data variations | | **Faker** | Random realistic data (names, emails, addresses) | Stress testing, large datasets | | **Seed data** | Known, reproducible database state | E2E tests needing a consistent baseline | | **API mocks** | Intercepted network responses | Integration/component tests without a backend | ### 9.2 Fixture Pattern ```json // src/test/fixtures/user.json { "id": 1, "name": "Alice Johnson", "email": "alice@example.com", "role": "admin" } ``` ```javascript // Using fixture in a test import userFixture from './fixtures/user.json'; it('renders user profile', () => { render(<UserProfile user={userFixture} />); expect(screen.getByText('Alice Johnson')).toBeInTheDocument(); }); ``` ### 9.3 Factory Pattern ```javascript // src/test/factories/user.js import { faker } from '@faker-js/faker'; export function buildUser(overrides = {}) { return { id: faker.number.int({ min: 1, max: 10000 }), name: faker.person.fullName(), email: faker.internet.email(), role: faker.helpers.arrayElement(['user', 'admin', 'moderator']), avatar: faker.image.avatar(), createdAt: faker.date.past().toISOString(), ...overrides, }; } // Usage const admin = buildUser({ role: 'admin', name: 'Admin User' }); const users = Array.from({ length: 20 }, () => buildUser()); ``` ### 9.4 Request Mocking Patterns ```javascript // MSW (Mock Service Worker) — recommended approach import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; const server = setupServer( http.get('/api/users/:id', ({ params }) => { return HttpResponse.json({ id: params.id, name: 'Mocked User', }); }), ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); it('fetches and displays user', async () => { render(<UserDetail userId="42" />); await waitFor(() => { expect(screen.getByText('Mocked User')).toBeInTheDocument(); }); }); ``` ### 9.5 Test Data Isolation | Concern | Solution | |---|---| | **State leakage between tests** | `afterEach` cleanup, MSW handler reset | | **Database state for E2E** | Database seeding before test suite, teardown after | | **Shared state in factories** | Return new objects, not singletons | | **Crypto-dependent IDs** | Deterministic faker seed: `faker.seed(123)` | | **Dates / timers** | Fake timers: `vi.useFakeTimers()` | ### 9.6 Test Data Setup Patterns ```javascript // Pattern 1: Setup in describe block describe('UserList', () => { const users = Array.from({ length: 5 }, (_, i) => buildUser({ id: i + 1 }) ); it('renders all users', () => { render(<UserList users={users} />); expect(screen.getAllByRole('listitem')).toHaveLength(5); }); }); // Pattern 2: Custom render function renderWithProviders(ui, { users = [], ...options } = {}) { const wrapper = ({ children }) => ( <UserProvider users={users}> {children} </UserProvider> ); return render(ui, { wrapper, ...options }); } // Pattern 3: Test factory function function setupUserList(overrides = {}) { const users = overrides.users ?? [buildUser()]; const onSelect = vi.fn(); const utils = render(<UserList users={users} onSelect={onSelect} />); return { ...utils, users, onSelect }; } ``` ### 9.7 Frontend Test Data Best Practices | Practice | Detail | |---|---| | Use realistic data | Catch real rendering issues, not just schema validation | | Separate data from assertions | Factories encourage readable, intention-revealing tests | | Prefer MSW over vi.mock | MSW works at the network level, doesn't need module mocking | | Seed deterministically | `faker.seed(123)` for reproducible failures | | Mock at the right level | API mocking > module mocking > global mocking | | Clean up between tests | Prevent state leakage that causes flaky tests | | Keep fixtures small | Only include fields the test actually exercises | --- ## References & Further Reading - **CSS Grid vs Flexbox**: blog.logrocket.com/css-flexbox-vs-css-grid - **Container Queries Guide**: caisy.io/blog/css-container-queries - **Beyond Media Queries (2025)**: medium.com/@orami98/beyond-media-queries - **Modern Breakpoint Strategy**: penpot.app/blog/how-to-use-css-and-media-query-breakpoints - **Playwright Accessibility Testing**: playwright.dev/docs/accessibility-testing - **Vitest + Testing Library Setup**: freecodecamp.org/news/how-to-test-react-applications-with-vitest - **Accessibility in CI/CD**: testparty.ai/blog/accessibility-testing-cicd - **Visual Regression Guide**: desplega.ai/blog/deep-dive-7-visual-regression-testing-ui-bugs - **Responsive Web Design Basics**: web.dev/articles/responsive-web-design-basics - **Ten Modern Layouts in One Line of CSS**: web.dev/articles/one-line-layouts -
state-management.md 1.8 KB
# State Management ## State Classification | State type | Where it lives | How to manage | Example | |-----------|----------------|---------------|---------| | Local UI state | Component | `useState`, `useReducer` | Form input values, toggle open/closed | | Shared UI state | Context or store | `useContext`, Zustand, Redux | Theme preference, sidebar collapsed state | | Server state | Cache layer | React Query, SWR, Apollo | User profile, product list, search results | | URL state | Browser URL | `useRouter`, search params | Current page, sort order, active filters | | Form state | Form library | React Hook Form, Formik | Form values, validation errors, submission status | ## Data Fetching Patterns | Pattern | When to use | Loading | Error | Empty | |---------|-------------|---------|-------|-------| | Fetch on render | Data needed immediately on page load | Skeleton/spinner | Error toast or inline error | Empty state message | | Fetch on interaction | Data needed after user action | Button loading state | Inline error next to trigger | Handle in response | | Prefetch | Next likely interaction | Background fetch, no loading UI | Silent failure, retry on explicit action | Handle on navigation | | Infinite scroll | Paginated lists | Loading indicator at bottom | Inline error with retry | "No more results" | | Optimistic update | Actions with predictable success | Instant UI update, rollback on error | Revert optimistic update, show error toast | N/A | ## Caching Strategy | Aspect | Approach | |--------|----------| | Cache duration | Configurable per query type (stale-while-revalidate) | | Invalidation | On mutation success, optimistic update, or manual refetch | | Deduplication | Identical in-flight queries share a single request | | Garbage collection | Unused cache entries evicted after configurable TTL |
-
-
scripts
-
bundle-budget-checker.py 6.3 KB
#!/usr/bin/env python3 """Bundle budget checker for frontend-engineering. Compares a JavaScript/CSS bundle size report against total and per-chunk byte budgets and fails when a budget is exceeded, so a performance regression stops the build instead of shipping silently. Input: a JSON file describing bundle chunks, in either of two shapes: {"chunks": [{"name": "main.js", "size": 180000}, ...]} # structured {"main.js": 180000, "vendor.js": 90000} # name -> bytes Budgets accept human units: 250KB, 1.5MB, 512000, 10 B (decimal KB/MB/GB or binary KiB/MiB/GiB). Exit codes: 0 all sizes within budget 1 one or more chunks or the total exceed budget 2 usage or input error (missing file, malformed JSON, bad budget value) """ import argparse import json import re import sys from pathlib import Path _UNIT_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?\s*$", re.IGNORECASE) _MULTIPLIERS = { "b": 1, "kb": 1000, "kib": 1024, "mb": 1000**2, "mib": 1024**2, "gb": 1000**3, "gib": 1024**3, } def parse_size(text): """Parse '250KB', '1.5MB', or plain byte counts into an int, or None.""" match = _UNIT_RE.match(text) if not match: return None value = float(match.group(1)) unit = (match.group(2) or "b").lower() return int(value * _MULTIPLIERS[unit]) def human_size(size): """Render a byte count in a compact human unit (binary).""" value = float(size) for unit in ("B", "KiB", "MiB", "GiB"): if value < 1024 or unit == "GiB": if unit == "B": return f"{int(value)} B" return f"{value:.1f} {unit}" value /= 1024 return f"{value:.1f} GiB" def load_chunks(path): """Load a bundle report into [(name, bytes)]; raises ValueError on bad input.""" try: raw = json.loads(Path(path).read_text(encoding="utf-8")) except OSError as exc: raise ValueError(f"cannot read {path}: {exc.strerror}") from exc except json.JSONDecodeError as exc: raise ValueError(f"{path}: invalid JSON: {exc.msg}") from exc if isinstance(raw, dict): if "chunks" in raw: chunks = raw["chunks"] if not isinstance(chunks, list): raise ValueError(f"{path}: 'chunks' must be a list") return [ (str(entry["name"]), int(entry["size"])) for entry in chunks if "name" in entry and "size" in entry ] return [(str(name), int(size)) for name, size in raw.items()] raise ValueError(f"{path}: report must be a JSON object of chunk names to sizes") def build_parser(): parser = argparse.ArgumentParser( prog="bundle-budget-checker.py", description=( "Check a bundle size report against total and per-chunk budgets. " 'Report format: {"chunks": [{"name": ..., "size": bytes}]} ' "or a plain {name: bytes} mapping." ), epilog="Exit codes: 0 within budget, 1 over budget, 2 usage or input error.", ) parser.add_argument("report", metavar="REPORT.json", help="bundle size report") parser.add_argument( "--total", metavar="SIZE", default=None, help="total budget for all chunks, e.g. 500KB or 512000", ) parser.add_argument( "--chunk", metavar="SIZE", default=None, help="per-chunk budget, e.g. 120KB; each chunk is checked individually", ) parser.add_argument("--json", action="store_true", help="emit a machine-readable JSON report") return parser def main(argv=None): parser = build_parser() args = parser.parse_args(argv) total_budget = parse_size(args.total) if args.total is not None else None chunk_budget = parse_size(args.chunk) if args.chunk is not None else None if args.total is not None and total_budget is None: print(f"ERROR: cannot parse budget {args.total!r}", file=sys.stderr) return 2 if args.chunk is not None and chunk_budget is None: print(f"ERROR: cannot parse budget {args.chunk!r}", file=sys.stderr) return 2 if total_budget is not None and total_budget < 0: print("ERROR: --total must not be negative", file=sys.stderr) return 2 if chunk_budget is not None and chunk_budget < 0: print("ERROR: --chunk must not be negative", file=sys.stderr) return 2 try: chunks = load_chunks(args.report) except ValueError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 total = sum(size for _, size in chunks) rows = [] over_budget = False for name, size in chunks: over = chunk_budget is not None and size > chunk_budget over_budget = over_budget or over rows.append( { "name": name, "bytes": size, "budget": chunk_budget, "status": "over" if over else "ok", } ) total_over = total_budget is not None and total > total_budget over_budget = over_budget or total_over if args.json: print( json.dumps( { "total": { "bytes": total, "budget": total_budget, "status": "over" if total_over else "ok", }, "chunks": rows, "over_budget": over_budget, }, indent=2, ) ) else: print("Bundle budget report") for row in rows: status = "OVER" if row["status"] == "over" else "OK" budget_text = human_size(row["budget"]) if row["budget"] is not None else "unset" print( f" {row['name']:<24} {human_size(row['bytes']):>10} " f"budget {budget_text:>8} {status}" ) total_budget_text = human_size(total_budget) if total_budget is not None else "unset" total_status = "OVER" if total_over else "OK" print( f" {'total':<24} {human_size(total):>10} " f"budget {total_budget_text:>8} {total_status}" ) if over_budget: print("Result: over budget", file=sys.stderr) else: print("Result: within budget") return 1 if over_budget else 0 if __name__ == "__main__": sys.exit(main()) -
test_bundle_budget_checker.py 7.9 KB
"""Tests for bundle-budget-checker.py. Covers: total and per-chunk budget enforcement, both input shapes (structured chunks list and name->bytes mapping), human unit parsing (250KB, 1.5MB), --json output, no-budget report mode, --help, and error paths (missing file, malformed JSON, bad budget value). Discoverable by both pytest and unittest (unittest.TestCase classes). """ import json import os import subprocess import sys import tempfile import unittest from contextlib import suppress SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) CHECKER = os.path.join(SCRIPTS_DIR, "bundle-budget-checker.py") def run_checker(args): cmd = [sys.executable, CHECKER, *args] proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) return proc.returncode, proc.stdout, proc.stderr def write_report(data): """Write a JSON bundle report to a temp file; return its path.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".json", delete=False, encoding="utf-8" ) as handle: json.dump(data, handle) path = handle.name return path def cleanup(path): with suppress(OSError): os.unlink(path) class TestBundleBudgetBudgets(unittest.TestCase): def test_within_total_budget(self): path = write_report({"chunks": [{"name": "main.js", "size": 100000}]}) try: rc, _, _ = run_checker([path, "--total", "250KB"]) self.assertEqual(rc, 0) finally: cleanup(path) def test_over_total_budget(self): path = write_report({"chunks": [{"name": "main.js", "size": 300000}]}) try: rc, _, stderr = run_checker([path, "--total", "250KB"]) self.assertEqual(rc, 1) self.assertIn("over budget", stderr) finally: cleanup(path) def test_over_chunk_budget(self): path = write_report( {"chunks": [{"name": "main.js", "size": 180000}, {"name": "vendor.js", "size": 90000}]} ) try: rc, stdout, _ = run_checker([path, "--chunk", "120KB"]) self.assertEqual(rc, 1) self.assertIn("main.js", stdout) self.assertIn("OVER", stdout) finally: cleanup(path) def test_all_chunks_within_chunk_budget(self): path = write_report({"chunks": [{"name": "main.js", "size": 100000}]}) try: rc, stdout, _ = run_checker([path, "--chunk", "200KB"]) self.assertEqual(rc, 0) self.assertIn("OK", stdout) finally: cleanup(path) def test_total_and_chunk_combined(self): path = write_report( {"chunks": [{"name": "main.js", "size": 80000}, {"name": "vendor.js", "size": 80000}]} ) try: # Within both budgets: total 160 KB <= 200 KB, each chunk <= 100 KB. rc_ok, _, _ = run_checker([path, "--total", "200KB", "--chunk", "100KB"]) self.assertEqual(rc_ok, 0) # Over chunk budget but within total: one chunk over 100 KB. rc_chunk, _, _ = run_checker([path, "--total", "300KB", "--chunk", "75KB"]) self.assertEqual(rc_chunk, 1) # Over total but within chunk budget. rc_total, _, _ = run_checker([path, "--total", "100KB", "--chunk", "100KB"]) self.assertEqual(rc_total, 1) finally: cleanup(path) def test_no_budget_reports_only(self): path = write_report({"chunks": [{"name": "main.js", "size": 180000}]}) try: rc, stdout, _ = run_checker([path]) self.assertEqual(rc, 0) self.assertIn("unset", stdout) self.assertIn("within budget", stdout) finally: cleanup(path) class TestBundleBudgetFormats(unittest.TestCase): def test_mapping_input_shape(self): path = write_report({"main.js": 180000, "vendor.js": 90000}) try: rc, stdout, _ = run_checker([path, "--total", "300KB"]) self.assertEqual(rc, 0) self.assertIn("main.js", stdout) self.assertIn("vendor.js", stdout) finally: cleanup(path) def test_empty_chunks_list(self): path = write_report({"chunks": []}) try: rc, stdout, _ = run_checker([path, "--total", "100KB"]) self.assertEqual(rc, 0) self.assertIn("within budget", stdout) finally: cleanup(path) class TestBundleBudgetParsing(unittest.TestCase): def test_unit_parsing_variants(self): path = write_report({"chunks": [{"name": "main.js", "size": 1024}]}) try: rc_ok, _, _ = run_checker([path, "--total", "1.5KB"]) self.assertEqual(rc_ok, 0) rc_bin, _, _ = run_checker([path, "--total", "1KiB"]) self.assertEqual(rc_bin, 0) rc_over, _, _ = run_checker([path, "--total", "512B"]) self.assertEqual(rc_over, 1) finally: cleanup(path) def test_plain_byte_budget(self): path = write_report({"chunks": [{"name": "main.js", "size": 512000}]}) try: rc, _, _ = run_checker([path, "--total", "512000"]) self.assertEqual(rc, 0) finally: cleanup(path) def test_mib_budget(self): path = write_report({"chunks": [{"name": "app.js", "size": 1500000}]}) try: rc, _, _ = run_checker([path, "--total", "2MiB"]) self.assertEqual(rc, 0) finally: cleanup(path) class TestBundleBudgetCli(unittest.TestCase): def test_help_exits_zero(self): rc, stdout, _ = run_checker(["--help"]) self.assertEqual(rc, 0) self.assertIn("budget", stdout) def test_json_output_parseable(self): path = write_report({"chunks": [{"name": "main.js", "size": 300000}]}) try: rc, stdout, _ = run_checker([path, "--total", "250KB", "--json"]) self.assertEqual(rc, 1) report = json.loads(stdout) self.assertTrue(report["over_budget"]) self.assertEqual(report["total"]["status"], "over") self.assertEqual(report["chunks"][0]["name"], "main.js") finally: cleanup(path) def test_json_within_budget(self): path = write_report({"chunks": [{"name": "main.js", "size": 100000}]}) try: rc, stdout, _ = run_checker([path, "--total", "250KB", "--json"]) self.assertEqual(rc, 0) report = json.loads(stdout) self.assertFalse(report["over_budget"]) self.assertEqual(report["chunks"][0]["status"], "ok") finally: cleanup(path) def test_missing_file_exit_two(self): rc, _, stderr = run_checker(["/nonexistent/report.json", "--total", "100KB"]) self.assertEqual(rc, 2) self.assertIn("ERROR", stderr) def test_malformed_json_exit_two(self): with tempfile.NamedTemporaryFile( mode="w", suffix=".json", delete=False, encoding="utf-8" ) as handle: handle.write("{not json") path = handle.name try: rc, _, stderr = run_checker([path, "--total", "100KB"]) self.assertEqual(rc, 2) self.assertIn("invalid JSON", stderr) finally: cleanup(path) def test_bad_budget_value_exit_two(self): path = write_report({"chunks": [{"name": "main.js", "size": 1000}]}) try: rc, _, stderr = run_checker([path, "--total", "lots"]) self.assertEqual(rc, 2) self.assertIn("cannot parse budget", stderr) finally: cleanup(path) def test_wrong_top_level_shape_exit_two(self): path = write_report(["main.js", "vendor.js"]) try: rc, _, stderr = run_checker([path, "--total", "100KB"]) self.assertEqual(rc, 2) self.assertIn("must be a JSON object", stderr) finally: cleanup(path) if __name__ == "__main__": unittest.main()
-
-
templates
-
component-state-design-record.md 2.9 KB
# Component / State Design Record Fill this record when designing a component tree or choosing state ownership for a feature. It captures the decomposition and the state decisions before implementation, so reviewers can validate the structure and future maintainers can see why state lives where it does. ## Feature - Feature or screen: `[fill: what is being built]` - Users and primary tasks: `[fill: who uses this and what they accomplish]` - Entry points: `[fill: routes, modals, or embed points that render this]` ## Component Tree Sketch the component decomposition: ``` [fill: top-level component] ├── [fill: child component] │ └── [fill: leaf component] ├── [fill: child component] └── [fill: child component] ``` - Composition rules: `[fill: which components are reusable vs feature-specific]` - Props interfaces: `[fill: the props each component takes and why they are minimal]` - What is NOT a component here: `[fill: repeated markup that should stay a component vs markup that stays inline]` ## State Ownership | State | Owner | Kind (local / shared / server) | Why here | |---|---|---|---| | `[fill: state]` | `[fill: component or context/store]` | `[fill: kind]` | `[fill: justification]` | - Local state: `[fill: what stays in useState/useReducer inside a component]` - Shared state: `[fill: what is shared and at what scope (component context, route, global)]` - Server state: `[fill: what is fetched and cached, and the cache/invalidation strategy]` ## Data Fetching | Data | Source endpoint | Cache key | Invalidation | States handled | |---|---|---|---|---| | `[fill: data]` | `[fill: endpoint]` | `[fill: key]` | `[fill: when it refetches]` | `[fill: loading/error/empty/success]` | - Optimistic updates: `[fill: which mutations update the cache optimistically and the rollback plan]` - Race handling: `[fill: how stale responses and rapid re-fetches are handled]` ## Error and Loading UX - Loading presentation: `[fill: skeletons, spinners, aria-busy usage]` - Error presentation: `[fill: per-error-state UI, retry affordances, 404 vs 5xx handling]` - Empty states: `[fill: what renders when data is valid but empty]` ## Accessibility and Responsive Notes - Keyboard and focus behavior: `[fill: focus management for modals/forms/loading transitions]` - Breakpoint behavior: `[fill: how the layout adapts and what changes per breakpoint]` ## Testing Plan - Component tests: `[fill: the interactions and states covered per component]` - Integration tests: `[fill: flows covered end to end through the component tree]` - Visual regression: `[fill: which screens are snapshotted]` ## Alternatives Considered - Alternative 1: `[fill: option considered]` — rejected because `[fill: reason]` - Alternative 2: `[fill: option considered]` — rejected because `[fill: reason]` ## Open Questions - `[fill: unresolved decision needing input before implementation]` -
performance-budget.md 2.8 KB
# Performance Budget Fill this budget when defining or reviewing frontend performance targets. Budgets are committed config, measured on every change, and enforced in CI so a regression fails the build instead of shipping silently. ## Budget Dimensions | Dimension | Metric | Budget | Measured by | Enforcement point | |---|---|---|---|---| | Bundle size | Initial JS (gzipped) | `[fill: e.g. 250 KB per route]` | Bundle report from the build | Build / CI script | | Bundle size | Initial CSS (gzipped) | `[fill: e.g. 50 KB]` | Build output | Build / CI script | | Bundle size | Largest single chunk | `[fill: e.g. 120 KB]` | Bundle report | `bundle-budget-checker` | | Loading | LCP | `[fill: e.g. 2.5 s]` | Lighthouse (lab + field) | CI Lighthouse job | | Stability | CLS | `[fill: e.g. 0.1]` | Lighthouse | CI Lighthouse job | | Responsiveness | INP | `[fill: e.g. 200 ms]` | Field data / lab | CI Lighthouse job | | Third-party | Script count / weight | `[fill: e.g. max 2 scripts, 50 KB]` | Request audit | CI check | ## Bundle Budget Fill in the enforced numbers for the `bundle-budget-checker` invocation: - Total budget for all route chunks: `[fill: bytes or human size, e.g. 512000 or 500KB]` - Per-chunk budget: `[fill: e.g. 120KB]` - Chunks exempt from the per-chunk budget (lazy-loaded vendors, web workers): `[fill: names and reason]` - Command used in CI: ``` [fill: e.g. python3 frontend-engineering/scripts/bundle-budget-checker.py dist/bundle-report.json --total 500KB --chunk 120KB] ``` ## Measurement Setup - Lab tooling: `[fill: Lighthouse CI config, mobile + desktop profiles, throttling]` - Field data source: `[fill: CrUX / RUM provider and the percentiles tracked, e.g. p75]` - Baseline commit and scores: `[fill: the recorded baseline so regressions are measured against it]` - How often measurements run: `[fill: every PR, nightly, on release]` ## Enforcement Workflow - Where budgets live: `[fill: committed file path]` - What happens when a PR exceeds a budget: `[fill: CI fails, alert channel, owner follows up]` - Escalation path for deliberate regressions: `[fill: who can approve an exception and how it is tracked]` ## Known Current Violations | Metric | Current value | Budget | Owner | Follow-up | |---|---|---|---|---| | `[fill: metric]` | `[fill: value]` | `[fill: budget]` | `[fill: owner]` | `[fill: linked issue]` | ## Review Checklist - [fill: check that] Initial render contains no unused heavy dependencies - [fill: check that] Images are sized, compressed, and dimensioned - [fill: check that] Fonts load with font-display swap and no layout shift - [fill: check that] Route-level code splitting is in place for every page - [fill: check that] Third-party scripts are deferred and counted in the budget - [fill: check that] Measurements are rerun after each change
-
-
README.md 2 KB
# Frontend Engineering Frontend engineering methodology — component architecture, state management, API integration, responsive layout, client-side performance, and frontend testing patterns. Framework agnostic, focused on web frontend implementation. ## Why Install This Skill Your agent applies proven component architecture, state management, and performance patterns instead of reinventing frontend structure each time. Fillable templates capture component/state design and performance budgets as reviewable records, and the bundled bundle-budget checker enforces performance budgets in CI. ## What You Get | Directory | Purpose | |-----------|---------| | `SKILL.md` | Core methodology, trigger conditions, reference index | | `references/` | Deep-dive reference files loaded on demand | | `templates/` | Fillable records: component/state design record, performance budget | | `scripts/` | `bundle-budget-checker.py` — checks bundle size reports against total and per-chunk budgets | | `evals/` | Output-quality eval manifest for the skill's methodology cases | ## Triggers Building UI components, choosing state management approaches, integrating APIs, optimizing Core Web Vitals, or setting up responsive layouts. ## Requirements Platform-agnostic. Framework-agnostic patterns applicable to React, Vue, Svelte, or vanilla JS. The bundled script needs only Python 3 (standard library). ## Quick Start Check a bundle size report against total and per-chunk budgets before merging a change: ```bash python3 frontend-engineering/scripts/bundle-budget-checker.py dist/bundle-report.json --total 500KB --chunk 120KB ``` The report maps chunk names to sizes (or a `{"chunks": [...]}` list from your bundler's analyzer). The script prints each chunk, the budget, and OK/OVER status, and exits 1 when any budget is exceeded — so it can gate CI. Add `--json` for machine-readable output. Load SKILL.md for the methodology overview and reference table, then load specific references as needed for the task at hand. -
SKILL.md 5.8 KB
--- name: frontend-engineering description: Build and maintain web frontends — component architecture, state management, API integration, responsive layout, client-side performance, and frontend testing patterns. Framework agnostic, focused on web frontend implementation. Do not use for backend service implementation, data engineering, or platform infrastructure work. license: MIT metadata: tags: frontend, web, ui, components, state-management, performance, javascript, typescript, responsive, testing source_repo: https://github.com/magnus919/hermes-profiles --- # Frontend Engineering Methodology Frontend engineering is the craft of building the user-facing layer of applications — components, state management, API integration, responsive layout, and client-side performance. This methodology bridges UX design (user journeys, wireframes, accessibility standards) and quality validation (qa-methodology). ## The Frontend Engineer's Domain | You own | You don't own | |---------|--------------| | Component implementation — UI component composition, props/state interfaces, rendering patterns, lifecycle | User journeys, wireframes, accessibility standards, interaction design — that's the product-design-and-ux | | State management — client-side state architecture, data fetching patterns, caching, optimistic updates | API contract design — that's the api-design-and-evolution | | API integration — frontend-to-backend data flow, auth flows (OAuth, JWT), real-time updates | Test strategy and automation — that's the QA-engineer | | Responsive design implementation — layout systems, breakpoints, cross-device testing | Visual identity and brand guidelines — that's the brand-designer | | Client-side performance — bundle optimization, lazy loading, Core Web Vitals, render optimization | Editorial content and copy — owned by the content and marketing team, outside this skill's scope | | Frontend testing — component tests, integration tests, visual regression, accessibility tests | Code review and quality gates — that's the qa-methodology | | E2E test scenarios and user-flow coverage for frontend features | Operating the browser test tool (Playwright) — authoring/running specs, selectors, network mocking, scraping — route to [playwright](../playwright/SKILL.md) | | Mobile app implementation (iOS/Android/Flutter/React Native) | Mobile platform work — scaffolding, builds and code signing, device/emulator testing, store submission, mobile lifecycle — route to [mobile-development](../mobile-development/SKILL.md); this skill owns web frontends | | Build tooling — bundler config, TypeScript config, linting, formatting, dev environment | CI/CD pipeline infrastructure — that's the platform-engineer | For React component and hooks work, load [react](../react/SKILL.md); for Vite config, modes, plugins, and builds, load [vite](../vite/SKILL.md). This skill remains the owner of framework-neutral architecture and frontend strategy. ## Reference Files | Reference | When to load | |-----------|-------------| | `references/component-architecture.md` | Designing component trees — composition patterns, props/state interfaces, lifecycle, accessibility fundamentals | | `references/state-management.md` | Choosing and implementing state management — client vs server state, data fetching, caching, optimistic updates | | `references/api-integration.md` | Connecting frontend to backend — API client design, auth token flow, error handling in the UI, real-time subscriptions | | `references/responsive-layout-testing.md` | Implementing responsive designs (layout system selection — Grid vs Flexbox vs Container Queries, breakpoint strategies, cross-device testing methodology) and testing frontend code (component testing with Testing Library, integration testing with Playwright/Cypress, visual regression, accessibility testing with axe-core and Lighthouse CI, test data management) | | `references/performance.md` | Optimizing client-side performance — Core Web Vitals, bundle analysis, code splitting, render optimization | ## Templates | Template | When to Use | |-----------|-------------| | `templates/component-state-design-record.md` | Designing a component tree and state ownership for a feature — decomposition, state scoping, data fetching, and error/loading UX | | `templates/performance-budget.md` | Defining performance targets — bundle byte budgets, Core Web Vitals budgets, measurement setup, and CI enforcement | ## Scripts | Script | When to Use | |-----------|-------------| | `scripts/bundle-budget-checker.py` | Checking a bundle size report against total and per-chunk budgets; fails (exit 1) when a budget is exceeded, so CI can block performance regressions | ## Core Principles **Components are the unit of composition, not pages** — Design and build components as reusable, composable units. Pages are assembled from components, not built as monoliths. A well-designed component can be reused in contexts its creator never imagined. **Co-locate state with the components that need it** — Not every piece of state belongs in a global store. Local state stays local. Server state is fetched and cached. Only truly shared application state belongs in a global context. **Design for every state, not just the happy path** — Every data-dependent component has at least four states: loading, empty, error, and success. Designing for all four is not a nicety — it creates a resilient user experience. **Accessibility is not a feature, it's a requirement** — Keyboard navigation, screen reader support, color contrast, and focus management are not enhancements. They are part of the implementation contract. **Performance is a UX concern** — Every millisecond of load time, every layout shift, every janky interaction erodes user trust. Performance budgeting, bundle analysis, and render optimization are part of frontend engineering, not an afterthought.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.