---
phase: 17-graph-filter-sidebar
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [web/src/stores/graphFilters.ts, web/src/lib/graph/filter.ts, web/src/components/graph/FilterControls.tsx, web/src/components/graph/FilterControls.css, web/src/components/graph/GraphView.tsx, web/src/components/graph/GraphCanvas.tsx]
autonomous: true
requirements: [GF-01]

must_haves:
  truths:
    - "Search input filters graph nodes live by name, path, aliases, and tags"
    - "Four visibility toggles live alongside Links/Backlinks/Shared Tags in the same flat panel"
    - "Tags toggle (ON) shows/hides tag nodes; Attachments toggle (OFF) shows/hides attachment nodes"
    - "Existing files only toggle (OFF) hides missing-file nodes when ON"
    - "Orphans toggle (ON) hides orphan nodes when OFF"
    - "Arrows toggle (OFF) controls link direction arrowheads"
    - "Text fade threshold slider (0–1, default 0.5) controls label opacity by zoom"
    - "Node size slider (0–1, default 0.2) controls node radius (2–12px)"
    - "Link thickness slider (0–1, default 0.2) controls edge stroke (0.5–5px)"
    - "Every control updates the graph immediately — no Apply button"
    - "Reset button restores all filter + display defaults"
    - "Filter state persisted to localStorage via Zustand persist middleware"
    - "Filtering pipeline is deterministic: node filter → edge filter → orphan filter → display settings"
    - "New group button creates a graph group with default name and color"
  artifacts:
    - path: "web/src/stores/graphFilters.ts"
      provides: "Zustand store for graph filter state (search, toggles, display settings, groups)"
      contains: "create<T>()(persist(...))"
    - path: "web/src/components/graph/FilterControls.tsx"
      provides: "Search input + visibility toggles + display sliders + groups button + reset"
      contains: "GraphFilterState"
  key_links:
    - from: "web/src/components/graph/GraphView.tsx"
      to: "web/src/stores/graphFilters.ts"
      via: "useGraphFilters hook reads filter state; filtering pipeline uses it"
    - from: "web/src/components/graph/FilterControls.tsx"
      to: "web/src/stores/graphFilters.ts"
      via: "FilterControls reads/writes filter state via useGraphFilters"
    - from: "web/src/components/graph/GraphCanvas.tsx"
      to: "web/src/stores/graphFilters.ts"
      via: "linkWidth/linkDirectionalArrowLength/nodeRelSize read from filter display settings"
---

<objective>
Add filtering and display controls flat into the existing GraphView header panel — same flat area as the Links/Backlinks/Shared Tags edge-type chips. Search input, 4 visibility toggles, display sliders, group button, and reset button, all wired for live graph updates via a new Zustand persist store and a deterministic client-side filtering pipeline. No collapsing, no separate sidebar, no backend changes.
</objective>

<execution_context>
@$HOME/.claude/anvil/workflows/execute-plan.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md §GF-01
@.planning/phases/17-graph-filter-sidebar/17-CONTEXT.md
</context>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: Create graphFilters Zustand store with persist middleware</name>
  <files>web/src/stores/graphFilters.ts</files>
  <read_first>
    - web/src/stores/graphEdges.ts — copy the Zustand persist pattern exactly
  </read_first>
  <behavior>
    New Zustand store `web/src/stores/graphFilters.ts` following the EXACT pattern from `graphEdges.ts`:
    - `create<GraphFilterState>()(persist((set) => ({...}), { name: "okf.graph.filters" }))`
    - Defaults: searchQuery="", showTags=true, showAttachments=false, existingFilesOnly=false, showOrphans=true, showArrows=false, textFadeThreshold=0.5, nodeSize=0.2, linkThickness=0.2, groups=[]
    - Actions: setSearchQuery, toggleTag, toggleAttachments, toggleExistingFilesOnly, toggleOrphans, toggleArrows, setTextFadeThreshold, setNodeSize, setLinkThickness, addGroup, reset (restores all defaults)
    - Export `useGraphFilters` hook (selective accessors matching the `useGraphEdges` pattern)
  </behavior>
  <action>
    1. `touch web/src/stores/graphFilters.ts`
    2. Write the store following `graphEdges.ts` structure exactly:
    ```ts
    import { create } from "zustand";
    import { persist } from "zustand/middleware";

    export interface GraphGroup {
      id: string;
      name: string;
      query: string;
      color: string;
      enabled: boolean;
    }

    interface GraphFilterState {
      searchQuery: string;
      showTags: boolean;
      showAttachments: boolean;
      existingFilesOnly: boolean;
      showOrphans: boolean;
      showArrows: boolean;
      textFadeThreshold: number;
      nodeSize: number;
      linkThickness: number;
      groups: GraphGroup[];
      setSearchQuery: (q: string) => void;
      toggleTag: () => void;
      toggleAttachments: () => void;
      toggleExistingFilesOnly: () => void;
      toggleOrphans: () => void;
      toggleArrows: () => void;
      setTextFadeThreshold: (v: number) => void;
      setNodeSize: (v: number) => void;
      setLinkThickness: (v: number) => void;
      addGroup: () => void;
      reset: () => void;
    }

    const DEFAULTS = { /* all default values */ };

    export const useGraphFilters = create<GraphFilterState>()(
      persist(
        (set) => ({
          ...DEFAULTS,
          setSearchQuery: (searchQuery) => set({ searchQuery }),
          toggleTag: () => set((s) => ({ showTags: !s.showTags })),
          toggleAttachments: () => set((s) => ({ showAttachments: !s.showAttachments })),
          toggleExistingFilesOnly: () => set((s) => ({ existingFilesOnly: !s.existingFilesOnly })),
          toggleOrphans: () => set((s) => ({ showOrphans: !s.showOrphans })),
          toggleArrows: () => set((s) => ({ showArrows: !s.showArrows })),
          setTextFadeThreshold: (textFadeThreshold) => set({ textFadeThreshold }),
          setNodeSize: (nodeSize) => set({ nodeSize }),
          setLinkThickness: (linkThickness) => set({ linkThickness }),
          addGroup: () => set((s) => ({
            groups: [...s.groups, {
              id: crypto.randomUUID(),
              name: "New group",
              query: "",
              color: "#8b5cf6",
              enabled: true,
            }],
          })),
          reset: () => set({ ...DEFAULTS, groups: [] }),
        }),
        { name: "okf.graph.filters" },
      ),
    );
    ```
    3. Fill DEFAULTS object with exact values from the spec
  </action>
  <acceptance_criteria>
    - `web/src/stores/graphFilters.ts` exists
    - grep 'create<GraphFilterState>()' web/src/stores/graphFilters.ts returns match
    - grep 'persist' web/src/stores/graphFilters.ts returns match
    - grep 'name: "okf.graph.filters"' web/src/stores/graphFilters.ts returns match
    - grep 'searchQuery' web/src/stores/graphFilters.ts returns match
    - grep 'showTags: true' web/src/stores/graphFilters.ts returns match
    - grep 'showAttachments: false' web/src/stores/graphFilters.ts returns match
    - grep 'showOrphans: true' web/src/stores/graphFilters.ts returns match
    - grep 'textFadeThreshold: 0.5' web/src/stores/graphFilters.ts returns match
    - grep 'nodeSize: 0.2' web/src/stores/graphFilters.ts returns match
    - grep 'linkThickness: 0.2' web/src/stores/graphFilters.ts returns match
    - grep 'reset' web/src/stores/graphFilters.ts returns match
    - npx tsc --noEmit -p web/tsconfig.json exits 0 (typechecks)
  </acceptance_criteria>
  <done>graphFilters store created with persist middleware, all defaults match spec, typechecks clean.</done>
</task>

<task type="implement" tdd="true">
  <name>Task 2: Add filterNodes + extend filtering pipeline in lib/graph/filter.ts</name>
  <files>web/src/lib/graph/filter.ts</files>
  <read_first>
    - web/src/lib/graph/filter.ts — current filterEdges function
    - web/src/lib/graph/model.ts — computeDegrees, isOrphan
    - web/src/api/client.ts — GraphNode, GraphEdge types
  </read_first>
  <behavior>
    Extend `web/src/lib/graph/filter.ts` with:
    - `matchesSearch(node, query)` — case-insensitive substring match against node.label, node.path, node.aliases, node.tags. Returns true if query is empty.
    - `filterNodes(nodes, filters)` — applies search + showTags + showAttachments + existingFilesOnly. Returns filtered node array.
    - `applyOrphanFilter(nodes, edges)` — removes nodes with 0 visible edges from the array, then removes edges referencing removed nodes. Returns { nodes, edges }.
    - `applyDisplaySettings(nodes, edges, filters)` — maps node radius and edge width/arrows from filter display settings. Returns { nodes, edges }.
    All functions are pure (no side effects, no DOM).
  </behavior>
  <action>
    1. Read `web/src/api/client.ts` to check GraphNode type (has `id`, `label`, `type`, `aliases?`, `tags?`, `path?`, `exists?`)
    2. Add to `web/src/lib/graph/filter.ts`:

    ```ts
    export function matchesSearch(node: GraphNode, query: string): boolean {
      const q = query.trim().toLowerCase();
      if (!q) return true;
      return (
        node.label?.toLowerCase().includes(q) ||
        node.id?.toLowerCase().includes(q) ||
        (node as { aliases?: string[] }).aliases?.some(a => a.toLowerCase().includes(q)) ||
        (node as { tags?: string[] }).tags?.some(t => t.toLowerCase().includes(q))
      );
    }

    export interface NodeFilters {
      searchQuery: string;
      showTags: boolean;
      showAttachments: boolean;
      existingFilesOnly: boolean;
    }

    export function filterNodes(nodes: GraphNode[], filters: NodeFilters): GraphNode[] {
      return nodes.filter(n => {
        if (!filters.showTags && n.type === "tag") return false;
        if (!filters.showAttachments && n.type === "attachment") return false;
        if (filters.existingFilesOnly && (n as { exists?: boolean }).exists === false) return false;
        if (!matchesSearch(n, filters.searchQuery)) return false;
        return true;
      });
    }

    export function applyOrphanFilter(
      nodes: GraphNode[],
      edges: GraphEdge[],
      showOrphans: boolean,
    ): { nodes: GraphNode[]; edges: GraphEdge[] } {
      if (showOrphans) return { nodes, edges };
      const connected = new Set<string>();
      for (const e of edges) { connected.add(e.source); connected.add(e.target); }
      const filteredNodes = nodes.filter(n => connected.has(n.id));
      const nodeIds = new Set(filteredNodes.map(n => n.id));
      const filteredEdges = edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target));
      return { nodes: filteredNodes, edges: filteredEdges };
    }
    ```
  3. Existing `filterEdges` function is unchanged (edge-type toggling is separate from visibility filtering)
  </action>
  <acceptance_criteria>
    - grep 'export function matchesSearch' web/src/lib/graph/filter.ts returns match
    - grep 'export function filterNodes' web/src/lib/graph/filter.ts returns match
    - grep 'export function applyOrphanFilter' web/src/lib/graph/filter.ts returns match
    - grep 'case-insensitive' web/src/lib/graph/filter.ts returns match (comment)
    - grep 'showTags' web/src/lib/graph/filter.ts returns match
    - grep 'showAttachments' web/src/lib/graph/filter.ts returns match
    - npx tsc --noEmit -p web/tsconfig.json exits 0
  </acceptance_criteria>
  <done>filterNodes, matchesSearch, and applyOrphanFilter added to lib/graph/filter.ts, all pure functions, typechecks clean.</done>
</task>

<task type="implement" tdd="true">
  <name>Task 3: Create FilterControls component</name>
  <files>web/src/components/graph/FilterControls.tsx, web/src/components/graph/FilterControls.css</files>
  <read_first>
    - web/src/components/graph/EdgeToggles.tsx — chip button pattern to replicate
    - web/src/components/graph/EdgeToggles.css — chip styling to replicate
    - web/src/stores/graphFilters.ts — the store created in Task 1
  </read_first>
  <behavior>
    Create `web/src/components/graph/FilterControls.tsx` — a component that renders inline in the GraphView header:
    - Search input: `<input type="search" placeholder="Search files..." />` with search icon, rounded dark background matching the graph panel style
    - Four toggle chips: Tags (ON), Attachments (OFF), Existing files only (OFF), Orphans (ON) — same pill-button pattern as EdgeToggles
    - Display section (flat, no collapsing): Arrows toggle (OFF) + three range sliders (Text fade threshold 0–1, Node size 0–1, Link thickness 0–1) with labels above
    - "New group" button: full-width purple rounded button
    - Reset button: restores all filter defaults
    - All controls read/write via `useGraphFilters` hook
    - Styling matches existing graph panel dark theme (tokens from tokens.css)
  </behavior>
  <action>
    1. `touch web/src/components/graph/FilterControls.tsx`
    2. `touch web/src/components/graph/FilterControls.css`
    3. Write FilterControls.tsx:

    ```tsx
    import { useGraphFilters } from "../../stores/graphFilters";
    import "./FilterControls.css";

    export default function FilterControls() {
      const searchQuery = useGraphFilters((s) => s.searchQuery);
      const showTags = useGraphFilters((s) => s.showTags);
      const showAttachments = useGraphFilters((s) => s.showAttachments);
      const existingFilesOnly = useGraphFilters((s) => s.existingFilesOnly);
      const showOrphans = useGraphFilters((s) => s.showOrphans);
      const showArrows = useGraphFilters((s) => s.showArrows);
      const textFadeThreshold = useGraphFilters((s) => s.textFadeThreshold);
      const nodeSize = useGraphFilters((s) => s.nodeSize);
      const linkThickness = useGraphFilters((s) => s.linkThickness);
      const setSearchQuery = useGraphFilters((s) => s.setSearchQuery);
      const toggleTag = useGraphFilters((s) => s.toggleTag);
      const toggleAttachments = useGraphFilters((s) => s.toggleAttachments);
      const toggleExistingFilesOnly = useGraphFilters((s) => s.toggleExistingFilesOnly);
      const toggleOrphans = useGraphFilters((s) => s.toggleOrphans);
      const toggleArrows = useGraphFilters((s) => s.toggleArrows);
      const setTextFadeThreshold = useGraphFilters((s) => s.setTextFadeThreshold);
      const setNodeSize = useGraphFilters((s) => s.setNodeSize);
      const setLinkThickness = useGraphFilters((s) => s.setLinkThickness);
      const addGroup = useGraphFilters((s) => s.addGroup);
      const reset = useGraphFilters((s) => s.reset);

      return (
        <div className="filtercontrols" role="group" aria-label="Graph filters">
          {/* Search */}
          <div className="filtercontrols-search">
            <span className="filtercontrols-search-icon" aria-hidden="true">⌕</span>
            <input
              type="search"
              className="filtercontrols-search-input"
              placeholder="Search files..."
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              aria-label="Search graph files"
            />
          </div>

          {/* Visibility toggles — same chip pattern as EdgeToggles */}
          <div className="filtercontrols-toggles" role="group" aria-label="Visibility toggles">
            <ToggleChip label="Tags" on={showTags} onToggle={toggleTag} />
            <ToggleChip label="Attachments" on={showAttachments} onToggle={toggleAttachments} />
            <ToggleChip label="Existing files only" on={existingFilesOnly} onToggle={toggleExistingFilesOnly} />
            <ToggleChip label="Orphans" on={showOrphans} onToggle={toggleOrphans} />
          </div>

          {/* Display controls */}
          <div className="filtercontrols-display">
            <ToggleChip label="Arrows" on={showArrows} onToggle={toggleArrows} />
            <SliderRow label="Text fade threshold" value={textFadeThreshold} min={0} max={1} step={0.01} onChange={setTextFadeThreshold} />
            <SliderRow label="Node size" value={nodeSize} min={0} max={1} step={0.01} onChange={setNodeSize} />
            <SliderRow label="Link thickness" value={linkThickness} min={0} max={1} step={0.01} onChange={setLinkThickness} />
          </div>

          {/* Groups */}
          <button type="button" className="filtercontrols-group-btn" onClick={addGroup}>
            New group
          </button>

          {/* Reset */}
          <button type="button" className="filtercontrols-reset" onClick={reset} aria-label="Reset graph filters">
            ↻ Reset
          </button>
        </div>
      );
    }

    function ToggleChip({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
      return (
        <button
          type="button"
          className={`edgetoggle-chip${on ? " edgetoggle-chip-on" : ""}`}
          aria-pressed={on}
          onClick={onToggle}
        >
          {label}
        </button>
      );
    }

    function SliderRow({ label, value, min, max, step, onChange }: {
      label: string; value: number; min: number; max: number; step: number;
      onChange: (v: number) => void;
    }) {
      return (
        <div className="filtercontrols-slider">
          <label className="filtercontrols-slider-label">{label}</label>
          <input
            type="range"
            className="filtercontrols-slider-input"
            value={value}
            min={min}
            max={max}
            step={step}
            onChange={(e) => onChange(parseFloat(e.target.value))}
            aria-label={label}
          />
        </div>
      );
    }
    ```
    4. Write FilterControls.css with dark theme styling matching tokens.css:
    ```css
    .filtercontrols { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
    .filtercontrols-search { position: relative; }
    .filtercontrols-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--color-faint, #9a9a9a); font-size: 14px; }
    .filtercontrols-search-input { height: 36px; width: 200px; border-radius: 8px; background: #202020; border: 1px solid var(--color-border, #3a3a3a); color: var(--color-text, #d4d4d4); padding: 0 12px 0 34px; font-size: 14px; }
    .filtercontrols-toggles { display: flex; gap: 6px; flex-wrap: wrap; }
    .filtercontrols-display { display: flex; flex-direction: column; gap: 10px; width: 100%; margin-top: 8px; }
    .filtercontrols-slider { }
    .filtercontrols-slider-label { font-size: 13px; color: var(--color-faint, #9a9a9a); display: block; margin-bottom: 4px; }
    .filtercontrols-slider-input { width: 100%; accent-color: var(--color-accent, #8b5cf6); }
    .filtercontrols-group-btn { width: 100%; height: 40px; border-radius: 6px; border: none; background: var(--color-accent, #8b5cf6); color: white; font-size: 15px; font-weight: 500; cursor: pointer; margin-top: 8px; }
    .filtercontrols-reset { background: none; border: 1px solid var(--color-border, #3a3a3a); color: var(--color-faint, #9a9a9a); border-radius: 6px; padding: 4px 10px; font-size: 13px; cursor: pointer; margin-top: 8px; }
    ```
  </action>
  <acceptance_criteria>
    - `web/src/components/graph/FilterControls.tsx` exists
    - `web/src/components/graph/FilterControls.css` exists
    - grep 'export default function FilterControls' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Search files' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Tags' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Attachments' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Existing files only' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Orphans' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Text fade threshold' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Node size' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Link thickness' web/src/components/graph/FilterControls.tsx returns match
    - grep 'New group' web/src/components/graph/FilterControls.tsx returns match
    - grep 'Reset graph filters' web/src/components/graph/FilterControls.tsx returns match
    - npx tsc --noEmit -p web/tsconfig.json exits 0
  </acceptance_criteria>
  <done>FilterControls component created with search, 4 toggles, 4 display controls, groups button, and reset button.</done>
</task>

<task type="implement" tdd="true">
  <name>Task 4: Wire FilterControls + filtering pipeline into GraphView</name>
  <files>web/src/components/graph/GraphView.tsx</files>
  <read_first>
    - web/src/components/graph/GraphView.tsx — current component to modify
    - web/src/components/graph/FilterControls.tsx — the new controls to integrate
    - web/src/stores/graphFilters.ts — filter state store
    - web/src/lib/graph/filter.ts — filterNodes, applyOrphanFilter
  </read_first>
  <behavior>
    Modify GraphView.tsx to:
    - Import and render `<FilterControls />` in the header alongside `<EdgeToggles />` (one flat panel)
    - Import and use `useGraphFilters` to read filter state
    - Apply the client-side filtering pipeline: filterNodes → filterEdges → applyOrphanFilter → display settings
    - Pass display settings (node size via `nodeRelSize`, link thickness via `linkWidth`, arrows via `linkDirectionalArrowLength`) to GraphCanvas
    - Text fade threshold controls label opacity in nodeCanvasObject (modify the existing label-drawing code to respect the threshold)
    - Live updates: every filter change immediately re-computes visible nodes/edges (no Apply button, no refetch)
  </behavior>
  <action>
    1. Read `web/src/components/graph/GraphView.tsx` (already in context)
    2. Add imports at top:
    ```tsx
    import { useGraphFilters } from "../../stores/graphFilters";
    import { filterNodes, applyOrphanFilter, type NodeFilters } from "../../lib/graph/filter";
    import FilterControls from "./FilterControls";
    ```
    3. Read filter state in the component body:
    ```tsx
    const searchQuery = useGraphFilters((s) => s.searchQuery);
    const showTags = useGraphFilters((s) => s.showTags);
    const showAttachments = useGraphFilters((s) => s.showAttachments);
    const existingFilesOnly = useGraphFilters((s) => s.existingFilesOnly);
    const showOrphans = useGraphFilters((s) => s.showOrphans);
    const showArrows = useGraphFilters((s) => s.showArrows);
    const textFadeThreshold = useGraphFilters((s) => s.textFadeThreshold);
    const filterNodeSize = useGraphFilters((s) => s.nodeSize);
    const filterLinkThickness = useGraphFilters((s) => s.linkThickness);
    ```
    4. Add node filtering useMemo BEFORE the existing `visibleEdges` useMemo:
    ```tsx
    const nodeFilters: NodeFilters = useMemo(() => ({
      searchQuery, showTags, showAttachments, existingFilesOnly,
    }), [searchQuery, showTags, showAttachments, existingFilesOnly]);

    const visibleNodes = useMemo(() => {
      if (!data) return [];
      return filterNodes(data.nodes, nodeFilters);
    }, [data, nodeFilters]);
    ```
    5. Modify the `visibleEdges` useMemo to also filter by visible node IDs:
    ```tsx
    const visibleNodeIds = useMemo(() => new Set(visibleNodes.map(n => n.id)), [visibleNodes]);

    const visibleEdges: GraphEdge[] = useMemo(() => {
      if (!data) return [];
      const edges = filterEdges(data.edges, { links, backlinks, sharedTags });
      return edges.filter(e => visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target));
    }, [data, links, backlinks, sharedTags, visibleNodeIds]);
    ```
    6. Add orphan filter AFTER edge filtering:
    ```tsx
    const orphanFiltered = useMemo(() => {
      return applyOrphanFilter(visibleNodes, visibleEdges, showOrphans);
    }, [visibleNodes, visibleEdges, showOrphans]);
    ```
    7. Use `orphanFiltered.nodes` and `orphanFiltered.edges` instead of `data.nodes` and `visibleEdges` in the `canvasData` useMemo and `degrees` useMemo.
    8. Map `filterNodeSize` 0–1 to `nodeRelSize` prop: `nodeRelSize={2 + filterNodeSize * 8}` (maps 0.2→3.6, fits the Obsidian dot-graph look)
    9. Map `filterLinkThickness` 0–1 in `linkWidth` callback: multiply the base width by the filter value
    10. Pass `linkDirectionalArrowLength={showArrows ? 4 : 0}` to GraphCanvas
    11. In `nodeCanvasObject`, use `textFadeThreshold` instead of the hardcoded `LABEL_ZOOM` constant
    12. Render `<FilterControls />` in the header, right after `<EdgeToggles />`:
    ```tsx
    <header className="graphview-header">
      <h1 className="graphview-title">Graph</h1>
      <EdgeToggles />
      <FilterControls />
    </header>
    ```
  </action>
  <acceptance_criteria>
    - grep 'FilterControls' web/src/components/graph/GraphView.tsx returns match
    - grep 'useGraphFilters' web/src/components/graph/GraphView.tsx returns match
    - grep 'filterNodes' web/src/components/graph/GraphView.tsx returns match
    - grep 'applyOrphanFilter' web/src/components/graph/GraphView.tsx returns match
    - grep 'visibleNodes' web/src/components/graph/GraphView.tsx returns match
    - grep 'orphanFiltered' web/src/components/graph/GraphView.tsx returns match
    - grep 'linkDirectionalArrowLength' web/src/components/graph/GraphView.tsx returns match
    - grep 'textFadeThreshold' web/src/components/graph/GraphView.tsx returns match
    - npx tsc --noEmit -p web/tsconfig.json exits 0
  </acceptance_criteria>
  <done>FilterControls integrated into GraphView header, filtering pipeline wired, display settings passed to GraphCanvas.</done>
</task>

</tasks>

<verification>
- `npx tsc --noEmit -p web/tsconfig.json` exits 0
- `grep 'okf.graph.filters' web/src/stores/graphFilters.ts` confirms persist key
- Search input filters graph nodes by typing in the text field (visual)
- Each toggle chip has correct default aria-pressed state (visual, checked via Playwright snapshot)
- Text fade threshold slider at 0.5; moving it changes label visibility (visual)
- Node size slider at 0.2; moving it changes node radius (visual)
- Link thickness slider at 0.2; moving it changes edge width (visual)
- Arrows toggle OFF; toggling ON shows arrowheads (visual)
- Reset button restores all defaults (visual + store check)
- "New group" button exists and is clickable (visual)
- No console errors throughout (programmatic)
</verification>

<success_criteria>
- All 15 GF-01 acceptance criteria met
- Filter controls live in the same flat panel as Links/Backlinks/Shared Tags
- No collapsing, no separate sidebar, no Apply button
- Filter state persisted to localStorage
- TypeScript compiles cleanly
</success_criteria>

<output>
Changes committed to the working tree. No new backend endpoints.
</output>
