The lead-authored .fractal/lead-prd.json and validated task DAG are ready.
Dog Three
A live Fractal execution graph shared by 1island.
Execution
Project graph
In the current workspace root, scaffold a React 18 + TypeScript + Vite single-page app named 'pawfinder' with a working Vitest setup. Create: (1) package.json with name 'pawfinder', private true, scripts { "dev": "vite", "build": "tsc --noEmit && vite build", "test": "vitest", "typecheck": "tsc --noEmit" }, dependencies react@^18.3.0 and react-dom@^18.3.0, and devDependencies: typescript@^5.5.0, vite@^5.4.0, @vitejs/plugin-react@^4.3.0, vitest@^2.0.0, jsdom@^25.0.0, @testing-library/react@^16.0.0, @testing-library/user-event@^14.5.0, @testing-library/jest-dom@^6.5.0, @types/react@^18.3.0, @types/react-dom@^18.3.0. (2) vite.config.ts using defineConfig from 'vitest/config' with the react plugin and a test block: { environment: 'jsdom', globals: true, setupFiles: './src/test-setup.ts' }. (3) src/test-setup.ts importing '@testing-library/jest-dom' and adding an afterEach that calls window.localStorage.clear(). (4) tsconfig.json targeting ES2020, jsx 'react-jsx', strict true, moduleResolution 'bundler', types including 'vitest/globals' and '@testing-library/jest-dom', include ['src']. (5) index.html with <div id="root"></div>, <title>PawFinder</title>, and a module script loading /src/main.tsx. (6) src/main.tsx mounting <App /> into #root with ReactDOM.createRoot and importing './styles.css'. (7) src/App.tsx as a temporary placeholder component rendering <h1>PawFinder</h1> (replaced later by app_integration). (8) src/styles.css as an empty placeholder file (replaced later by theme_styles). (9) src/types.ts exporting: type BreedSize = 'small' | 'medium' | 'large'; type EnergyLevel = 'low' | 'medium' | 'high'; interface Breed { id: string; name: string; group: string; size: BreedSize; energy: EnergyLevel; temperament: string[]; lifeExpectancy: string; kidFriendly: 1|2|3|4|5; petFriendly: 1|2|3|4|5; grooming: 1|2|3|4|5; trainability: 1|2|3|4|5; apartmentFriendly: boolean; goodForFirstTimeOwners: boolean; emoji: string; description: string; }; interface QuizAnswers { homeSize: 'apartment' | 'house-small-yard' | 'house-large-yard'; activityLevel: 'low' | 'moderate' | 'high'; hasKids: boolean; groomingTolerance: 'low' | 'medium' | 'high'; firstTimeOwner: boolean; }; interface BreedMatch { breed: Breed; score: number; }. Also create .gitignore covering node_modules and dist. Run `npm install` and verify it completes, then verify `npx tsc --noEmit` exits 0.
Create src/data/breeds.ts exporting `export const breeds: Breed[]` (importing Breed from '../types') containing exactly 32 real, well-known dog breeds spanning all sizes and energy levels — include at least: Labrador Retriever, Golden Retriever, German Shepherd, French Bulldog, Poodle, Beagle, Border Collie, Cavalier King Charles Spaniel, Greyhound, Shih Tzu, Siberian Husky, Dachshund, Great Dane, Corgi (Pembroke Welsh), Boxer, Australian Shepherd, Chihuahua, Bernese Mountain Dog, Pug, Basset Hound. Every field of the Breed interface must be filled with plausible, factually reasonable values: id is the kebab-case name (e.g. 'labrador-retriever'), group is the AKC group (e.g. 'Sporting', 'Herding', 'Toy'), temperament has 3-5 adjectives, lifeExpectancy is a range string like '10-12 years', the five 1-5 ratings reflect real breed characteristics, emoji is a single dog-relevant emoji, and description is 2-3 original sentences about the breed's personality and fit. Ensure the dataset includes at least 6 breeds of each size, at least 6 of each energy level, at least 8 with apartmentFriendly true, and at least 8 with goodForFirstTimeOwners true, so filters and quiz results are never empty. Also export `export function getBreedById(id: string): Breed | undefined` returning the matching breed. Verify with `npx tsc --noEmit`.
Create src/hooks/useFavorites.ts exporting `export const FAVORITES_KEY = 'pawfinder.favorites'` and `export function useFavorites(): { favorites: string[]; isFavorite: (id: string) => boolean; toggleFavorite: (id: string) => void }`. Implementation requirements: initialize state lazily by reading localStorage.getItem(FAVORITES_KEY) and JSON.parse-ing it inside a try/catch — on any error, missing key, or a parsed value that is not an array of strings, fall back to []. toggleFavorite adds the id if absent or removes it if present (no duplicates ever), updates React state, and writes the new array back with localStorage.setItem(FAVORITES_KEY, JSON.stringify(next)) inside a try/catch so a storage failure (e.g. quota) never throws into the UI. isFavorite is O(1)-ish via includes and stable behavior. Use only React's useState/useCallback — no external state libraries. Verify with `npx tsc --noEmit`.
Create src/lib/filterBreeds.ts importing Breed, BreedSize, EnergyLevel from '../types'. Export `export interface BreedFilters { query: string; size: BreedSize | 'all'; energy: EnergyLevel | 'all'; }`, `export const emptyFilters: BreedFilters = { query: '', size: 'all', energy: 'all' }`, and `export function filterBreeds(breeds: Breed[], filters: BreedFilters): Breed[]` which: (1) never mutates the input array; (2) matches query case-insensitively as a substring against breed.name (trim the query; empty/whitespace query matches everything); (3) applies size and energy only when not 'all'; (4) combines all active criteria conjunctively (AND); (5) returns results sorted alphabetically by name. Also export `export function sortByName(breeds: Breed[]): Breed[]` returning a new alphabetically sorted array (used internally). The functions must be pure with no DOM or React imports. Verify with `npx tsc --noEmit`.
Create src/lib/matchBreeds.ts importing Breed, QuizAnswers, BreedMatch from '../types'. Export `export function scoreBreed(breed: Breed, answers: QuizAnswers): number` returning an integer 0-100, computed from a weighted rubric (weights summing to 100): homeSize 25 points (apartment → full points only if breed.apartmentFriendly and size !== 'large'; house-small-yard → full for small/medium, half for large; house-large-yard → full for all); activityLevel 30 points (full when breed.energy exactly matches low↔low, moderate↔medium, high↔high; half when adjacent, e.g. moderate vs high; zero when opposite, e.g. low vs high); hasKids 20 points (when true, scale by breed.kidFriendly/5; when false, award full points); groomingTolerance 15 points (low tolerance → scale by (6 - breed.grooming)/5; medium → full unless grooming is 5, then half; high → full); firstTimeOwner 10 points (when true, full only if breed.goodForFirstTimeOwners, else scale by breed.trainability/5 capped at half; when false, full). Round the final sum with Math.round and clamp to [0,100]. Also export `export function matchBreeds(breeds: Breed[], answers: QuizAnswers, limit = 5): BreedMatch[]` returning the top `limit` breeds sorted by descending score, breaking score ties alphabetically by name so results are fully deterministic, without mutating the input. No DOM or React imports; no use of Math.random or Date. Verify with `npx tsc --noEmit`.
Replace the placeholder src/styles.css with a complete design system for PawFinder. Define :root custom properties: a warm palette (--color-bg: #faf7f2; --color-surface: #ffffff; --color-primary: #b45309; --color-primary-soft: #fde8d3; --color-text: #292524; --color-text-muted: #78716c; --color-border: #e7e0d8; --color-danger: #b91c1c), a spacing scale (--space-1: 4px through --space-6: 32px), radii (--radius-sm: 6px; --radius-md: 12px), and a system font stack. Style these class hooks that the UI components will use (implement all of them): .app-header (sticky top nav with brand and nav links, active link visibly distinguished), .breed-grid (CSS grid, minmax(240px, 1fr) auto-fill, gap var(--space-4)), .breed-card (surface card with border, radius, hover elevation via box-shadow and transform, cursor pointer), .breed-card__emoji (large emoji), .badge and .badge--size / .badge--energy (small pill labels), .filter-bar (flex row wrapping on narrow screens, styled text input and selects with visible :focus-visible outlines), .empty-state (centered muted message), .detail-page (max-width 720px article layout with a stats definition list .detail-stats), .favorite-btn and .favorite-btn--active (clear pressed/unpressed visual difference beyond color alone — e.g. filled vs outlined heart character and border change), .quiz-form (stacked fieldsets with legends, radio/checkbox labels as large click targets), .match-list (ranked results with a .match-score percentage and a horizontal .match-bar whose width reflects the score), and .not-found. Ensure WCAG-reasonable contrast (body text ≥ 4.5:1 against backgrounds), :focus-visible outlines on all interactive elements, and a single @media (max-width: 480px) block that stacks the filter bar and reduces grid to one column. No CSS frameworks.
Create src/components/BreedDetail.tsx exporting `export function BreedDetail({ breedId, isFavorite, onToggleFavorite, onBack }: { breedId: string; isFavorite: boolean; onToggleFavorite: (id: string) => void; onBack: () => void })`. It imports getBreedById from '../data/breeds'. If getBreedById(breedId) returns undefined, render <div class="not-found"><h2>Breed not found</h2></div> plus a 'Back to all breeds' button calling onBack. Otherwise render an <article class="detail-page">: an 'All breeds' back button (onBack), an <h2> with the breed emoji and name, the group and lifeExpectancy, the description paragraph, a temperament tag row rendering each temperament string as a .badge, and a <dl class="detail-stats"> with <dt>/<dd> pairs for Size, Energy, Kid-friendly (render rating as '4/5'), Pet-friendly, Grooming needs, Trainability, Apartment friendly (Yes/No), and Good for first-time owners (Yes/No). Include a favorite toggle <button class="favorite-btn"> that calls onToggleFavorite(breed.id), has aria-pressed={isFavorite}, class 'favorite-btn favorite-btn--active' when favorited, and accessible text 'Remove from favorites' when favorited / 'Add to favorites' when not (with a ♥/♡ character marked aria-hidden). Verify with `npx tsc --noEmit`.
Create src/components/BreedList.tsx exporting `export function BreedList({ onSelectBreed }: { onSelectBreed: (id: string) => void })`. It imports breeds from '../data/breeds', filterBreeds/emptyFilters/BreedFilters from '../lib/filterBreeds', and Breed types from '../types'. Behavior: local useState<BreedFilters> starting at emptyFilters; render a .filter-bar containing (1) a text input with placeholder 'Search breeds…' and aria-label 'Search breeds' bound to filters.query, (2) a <select> with aria-label 'Filter by size' offering All sizes/small/medium/large, (3) a <select> with aria-label 'Filter by energy' offering All energy levels/low/medium/high. Below, render the filtered result of filterBreeds(breeds, filters): when non-empty, a <ul class="breed-grid"> of <li> .breed-card items — each card is a <button> (full-card click target) with aria-label `View ${breed.name}` calling onSelectBreed(breed.id), showing breed.emoji in .breed-card__emoji, the name in an <h3>, the group in muted text, and two badges (.badge--size showing size, .badge--energy showing energy); when empty, render <p class="empty-state">No breeds found. Try different filters.</p>. Also render a results count line like '32 breeds' / '1 breed' with correct pluralization. Verify with `npx tsc --noEmit`.
Create src/components/FavoritesPage.tsx exporting `export function FavoritesPage({ favoriteIds, onSelectBreed, onToggleFavorite }: { favoriteIds: string[]; onSelectBreed: (id: string) => void; onToggleFavorite: (id: string) => void })`. It imports breeds/getBreedById from '../data/breeds'. Resolve favoriteIds to Breed objects (silently skipping any id with no matching breed), sorted alphabetically by name. When the resolved list is empty, render <div class="empty-state"><h2>No favorites yet</h2><p>Browse the breeds and tap the heart to save the dogs you love.</p></div>. Otherwise render an <h2>Your favorites</h2> and a <ul class="breed-grid"> of .breed-card items mirroring BreedList's card layout (emoji, name, group, size/energy badges, card button with aria-label `View ${breed.name}` calling onSelectBreed), each additionally containing a 'Remove from favorites' button with aria-label `Remove ${breed.name} from favorites` that calls onToggleFavorite(breed.id) and stops propagation so it does not trigger card navigation. Verify with `npx tsc --noEmit`.
Create three test files (Vitest with globals, jsdom, per the existing vite.config.ts). (1) src/lib/__tests__/filterBreeds.test.ts: build a small local fixture Breed[] (import Breed from '../../types', do not import the real dataset) with known names/sizes/energies including 'Labrador Retriever' (large/high) and 'Golden Retriever' (large/high) plus small/low fixtures; assert filterBreeds returns the full array (sorted by name) for emptyFilters; matches query 'retriever' case-insensitively returning exactly the two retrievers; returns [] for query 'zzzz'; combines query+size+energy conjunctively (e.g. size 'large' + energy 'high' excludes small/low fixtures); trims whitespace-only queries; and does not mutate the input array (assert original order unchanged after calling). (2) src/lib/__tests__/matchBreeds.test.ts: with a fixture Breed[] of at least 7 breeds spanning traits, assert matchBreeds(fixtures, answers, 5) returns exactly 5 BreedMatch results sorted by non-increasing score; every score is an integer within [0,100]; for answers { homeSize: 'house-large-yard', activityLevel: 'high', hasKids: false, groomingTolerance: 'high', firstTimeOwner: false } a high-energy fixture scores strictly higher than a low-energy fixture; for an apartment/low-activity answer set an apartmentFriendly low-energy small fixture ranks first; and calling twice with identical inputs yields deeply equal results (determinism). (3) src/hooks/__tests__/useFavorites.test.tsx: using renderHook and act from @testing-library/react, assert favorites starts empty; toggleFavorite('beagle') makes isFavorite('beagle') true and localStorage.getItem('pawfinder.favorites') parse to ['beagle']; toggling again removes it (state and storage); a second renderHook after toggling (fresh mount) reads the persisted favorite; and when localStorage is pre-seeded with 'not-json' or '{"a":1}' the hook initializes to [] without throwing. Run `npx vitest run src/lib src/hooks` and ensure all three files pass; fix any test-side issues (do not weaken assertions).
Create src/components/QuizPage.tsx exporting `export function QuizPage({ onSelectBreed }: { onSelectBreed: (id: string) => void })`. It imports breeds from '../data/breeds', matchBreeds from '../lib/matchBreeds', and QuizAnswers from '../types'. Render a <form class="quiz-form"> with five <fieldset> groups, each with a <legend>: (1) legend 'What is your home like?' — radios named homeSize with labels 'Apartment', 'House with a small yard', 'House with a large yard'; (2) legend 'How active is your lifestyle?' — radios named activityLevel with labels 'Mostly relaxed', 'Moderately active', 'Very active'; (3) legend 'Do you have kids at home?' — radios named hasKids with labels 'Yes'/'No'; (4) legend 'How much grooming can you handle?' — radios named groomingTolerance with labels 'As little as possible', 'Some upkeep is fine', 'I enjoy grooming'; (5) legend 'Is this your first dog?' — radios 'Yes'/'No'. Hold answers in useState<Partial state>; the submit button <button type="submit">Find my match</button> is disabled until every question is answered. On submit (preventDefault), compute matchBreeds(breeds, answers, 5) and render below the form an <h2>Your top matches</h2> and an <ol class="match-list"> where each <li> shows the rank, breed emoji and name as a button calling onSelectBreed(breed.id), a <span class="match-score">{score}% match</span>, and a .match-bar div whose inline style width is `${score}%`. Submitting again with changed answers recomputes the results. Verify with `npx tsc --noEmit`.
Create src/hooks/useHashRoute.ts exporting `export function useHashRoute(): { route: string; navigate: (to: string) => void }` where route is window.location.hash stripped of the leading '#' (defaulting to '/'), kept in React state and synced via a 'hashchange' window listener (added in useEffect, removed on cleanup), and navigate(to) sets window.location.hash. Then replace the placeholder src/App.tsx with the real shell: it imports useHashRoute, useFavorites, BreedList, BreedDetail, FavoritesPage, and QuizPage. Render a <header class="app-header"> with the brand '🐾 PawFinder' (an <a href="#/">) and a <nav> with links: <a href="#/">Breeds</a>, <a href="#/favorites">Favorites</a> (append a count like 'Favorites (2)' when favorites.length > 0), <a href="#/quiz">Find your match</a>; mark the active link with aria-current="page" based on the current route. Route resolution inside <main>: '/' → <BreedList onSelectBreed={(id) => navigate(`/breed/${id}`)} />; a route matching '/breed/:id' (parse the id from the route string) → <BreedDetail breedId={id} isFavorite={isFavorite(id)} onToggleFavorite={toggleFavorite} onBack={() => navigate('/')} />; '/favorites' → <FavoritesPage favoriteIds={favorites} onSelectBreed={(id) => navigate(`/breed/${id}`)} onToggleFavorite={toggleFavorite} />; '/quiz' → <QuizPage onSelectBreed={(id) => navigate(`/breed/${id}`)} />; any other route → the BreedList (safe fallback). The useFavorites hook is instantiated once here in App so favorite state is shared across all views. Verify `npx tsc --noEmit` exits 0 and `npm run build` succeeds.
Read fractal-plan.json in the workspace root and extract the seven acceptance criteria AC-1 through AC-7. Then read every file under src/ (components, lib, hooks, data, tests, App.tsx, styles.css) and produce a written audit file AUDIT.md in the workspace root containing a table with one row per acceptance criterion listing: the criterion id, the implementing source files, the verifying test file(s) and specific test names, and a PASS/GAP verdict on whether the described observable behavior is actually implemented and actually asserted by a test (not merely rendered). Additionally check and report on: (a) the dataset has 32 breeds and satisfies the distribution minimums from the plan (≥6 per size, ≥6 per energy level, ≥8 apartmentFriendly, ≥8 goodForFirstTimeOwners); (b) matchBreeds contains no Math.random/Date usage; (c) every interactive element in the components has an accessible name (aria-label, label association, or text content); (d) useFavorites guards both JSON.parse and localStorage.setItem with try/catch. For any GAP found, append a 'Required fixes' section with the exact file and change needed. Do not modify source files — this task only writes AUDIT.md.
Create three test files using @testing-library/react and @testing-library/user-event against the real components and real dataset. (1) src/components/__tests__/BreedList.test.tsx: render <BreedList onSelectBreed={vi.fn()} />; import breeds from '../../data/breeds' and assert the number of rendered breed cards (queryAllByRole('button', { name: /^View /i })) equals breeds.length; type 'retriever' into the 'Search breeds' textbox and assert only breeds whose name matches /retriever/i remain; type 'zzzz' and assert screen.getByText(/no breeds found/i) appears with zero cards; clear the input and assert all breeds return; select size 'large' and energy 'high' via the labeled comboboxes and assert every visible card corresponds to a breed with those exact traits; click a card and assert onSelectBreed was called with that breed's id. (2) src/components/__tests__/BreedDetail.test.tsx: render BreedDetail with breedId 'labrador-retriever' (and stub handlers) and assert the name, group, life expectancy text, description substring, every temperament badge, and the stats for size/energy render; assert the favorite button has aria-pressed="false" when isFavorite is false and clicking it calls onToggleFavorite with 'labrador-retriever'; re-render with isFavorite true and assert aria-pressed="true" and the 'Remove from favorites' accessible name; render with breedId 'does-not-exist' and assert 'Breed not found' renders without throwing. (3) src/__tests__/App.integration.test.tsx: beforeEach set window.location.hash = '#/' and clear localStorage; render <App />; click the 'View Beagle' card and assert the detail heading /beagle/i renders; click 'Add to favorites'; fire a navigation to '#/favorites' (set hash and dispatch new HashChangeEvent('hashchange')) and assert Beagle appears on the favorites page and the nav shows 'Favorites (1)'; navigate to '#/quiz' and assert the legend 'What is your home like?' renders; complete all five quiz questions via user-event radio clicks, click 'Find my match', and assert an ordered list with exactly 5 match items each containing text matching /% match/ renders; finally assert localStorage 'pawfinder.favorites' still contains 'beagle' (persistence across navigation). Run `npx vitest run src/components src/__tests__` and ensure all pass; fix test-side issues without weakening assertions, and if a genuine component bug is exposed, fix the component to match the PRD behavior.
First read AUDIT.md in the workspace root: if it lists any 'Required fixes', apply each one to the named source or test files (keeping all existing test assertions at full strength). Then run the acceptance gate from a clean state, in order: (1) `npm install` — must complete without errors; (2) `npx tsc --noEmit` — must exit 0 with no type errors; (3) `npm test -- --run` — must exit 0 with every test file passing, which verifies acceptance criteria AC-1 through AC-7 of fractal-plan.json (BreedList search/filter/empty-state tests for AC-1 and AC-2's UI half, filterBreeds unit tests for AC-2, BreedDetail tests for AC-3, useFavorites persistence/corruption tests for AC-4, matchBreeds determinism/ranking tests for AC-5, App integration navigation test for AC-6, and the overall clean run for AC-7); (4) `npm run build` — must produce a dist/ bundle with no errors. If any step fails, diagnose from the exact error output, fix the responsible source or test file, and re-run the failed step and then the full `npm test -- --run` until everything passes. Conclude by appending a 'Verification results' section to AUDIT.md recording the final exit status of all four commands and the test file/test counts from the Vitest summary.
Review the finished implementation against .fractal/lead-prd.json. Inspect the changes and verification evidence, run any final checks needed, then write .fractal/closeout.json with schema fractal.closeout.v1, status approved, a non-empty summary, an acceptance array containing every PRD acceptance id with passed=true and concrete evidence, and a risks array. Do not approve if any criterion is unsupported.
Contribute