diff --git a/docs/superpowers/plans/2026-05-13-screener-node-canvas.md b/docs/superpowers/plans/2026-05-13-screener-node-canvas.md
new file mode 100644
index 0000000..b603d53
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-13-screener-node-canvas.md
@@ -0,0 +1,1826 @@
+# Screener Node Canvas Mode Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `/stock/screener` 에 n8n 스타일 노드 캔버스 모드를 추가한다. 폼 모드와 토글로 전환하며 같은 settings state를 공유하고, 11 노드 / 16 엣지 고정 토폴로지로 파이프라인을 시각화한다.
+
+**Architecture:** 백엔드 변경 없이 프론트 전용. `@xyflow/react` 도입, `useScreenerMode` / `useCanvasLayout` 두 hook 추가, `canvas/` 하위에 노드 카드 + 캔버스 컨테이너 + floating 툴바 컴포넌트 신설. 모드 분기는 `Screener.jsx` 한 곳에서 처리하고 결과 영역(`ResultTable`, `TelegramPreview`, `RunHistoryList`)은 두 모드가 공유. 모바일은 `useIsMobile` 분기로 폼 강제.
+
+**Tech Stack:** React 18.2, Vite 7, `@xyflow/react` ^12, vitest + jsdom + @testing-library/react, localStorage.
+
+**선행 spec**: `docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md`
+
+---
+
+## 파일 구조
+
+신규 파일:
+```
+src/pages/stock/screener/
+ hooks/
+ useScreenerMode.js
+ useCanvasLayout.js
+ components/
+ ModeToggle.jsx
+ canvas/
+ CanvasLayout.jsx
+ ScreenerCanvas.jsx
+ CanvasToolbar.jsx
+ nodes/
+ ScoreNodeCard.jsx
+ GateNodeCard.jsx
+ FixedNodeCard.jsx
+ constants/
+ canvasLayout.js
+ Canvas.css
+
+src/test-setup.js
+```
+
+수정 파일:
+```
+package.json (의존성 추가, test script)
+vite.config.js (test 섹션)
+src/pages/stock/screener/Screener.jsx
+src/pages/stock/screener/Screener.css (ModeToggle 스타일 추가)
+```
+
+테스트 파일:
+```
+src/pages/stock/screener/hooks/useScreenerMode.test.js
+src/pages/stock/screener/hooks/useCanvasLayout.test.js
+src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
+src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx
+```
+
+---
+
+### Task 1: 의존성 + 테스트 환경 셋업
+
+**Files:**
+- Modify: `package.json`
+- Modify: `vite.config.js`
+- Create: `src/test-setup.js`
+
+- [ ] **Step 1: `@xyflow/react` 와 테스트 의존성 설치**
+
+```bash
+cd C:\Users\jaeoh\Desktop\workspace\web-ui
+npm install @xyflow/react@^12
+npm install -D vitest@^2 jsdom@^25 @testing-library/react@^16 @testing-library/jest-dom@^6 @testing-library/user-event@^14
+```
+
+Expected: 새 의존성 6개가 `package.json` 에 추가됨. `npm install` 성공.
+
+- [ ] **Step 2: `package.json` 에 test 스크립트 추가**
+
+`scripts` 객체에 다음 줄을 추가:
+```json
+"test": "vitest",
+"test:run": "vitest run"
+```
+
+- [ ] **Step 3: `vite.config.js` 에 test 섹션 추가**
+
+기존 `defineConfig({ ... })` 블록 안 `plugins` 옆에 추가:
+```js
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test-setup.js'],
+ include: ['src/**/*.test.{js,jsx}'],
+ },
+```
+
+- [ ] **Step 4: `src/test-setup.js` 작성**
+
+```js
+import '@testing-library/jest-dom/vitest';
+
+// jsdom polyfills for react-flow
+if (typeof window !== 'undefined') {
+ if (!window.matchMedia) {
+ window.matchMedia = (query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ });
+ }
+ if (!window.ResizeObserver) {
+ window.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+ }
+ if (!window.DOMMatrixReadOnly) {
+ window.DOMMatrixReadOnly = class {
+ constructor() {}
+ m22 = 1;
+ };
+ }
+}
+
+beforeEach(() => {
+ localStorage.clear();
+});
+```
+
+- [ ] **Step 5: 셋업 동작 확인**
+
+```bash
+npx vitest --run --reporter=verbose
+```
+Expected: "No test files found" 메시지 (테스트 파일이 아직 없어 정상). 에러 없이 종료.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add package.json package-lock.json vite.config.js src/test-setup.js
+git commit -m "chore(screener): add @xyflow/react + vitest test environment"
+```
+
+---
+
+### Task 2: `canvasLayout.js` 상수 + 정합성 테스트
+
+**Files:**
+- Create: `src/pages/stock/screener/components/canvas/constants/canvasLayout.js`
+- Test: `src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js`
+
+- [ ] **Step 1: 실패하는 정합성 테스트 작성**
+
+```js
+import { describe, it, expect } from 'vitest';
+import {
+ NODE_IDS, INITIAL_NODE_POSITIONS, EDGES,
+ NODE_KIND_MAP, SCORE_NODE_NAME_MAP,
+} from './canvasLayout';
+
+describe('canvasLayout', () => {
+ it('NODE_IDS — 11개 키, 모두 unique', () => {
+ const ids = Object.values(NODE_IDS);
+ expect(ids).toHaveLength(11);
+ expect(new Set(ids).size).toBe(11);
+ });
+
+ it('INITIAL_NODE_POSITIONS — 모든 NODE_IDS에 좌표 존재', () => {
+ for (const id of Object.values(NODE_IDS)) {
+ expect(INITIAL_NODE_POSITIONS[id]).toMatchObject({
+ x: expect.any(Number),
+ y: expect.any(Number),
+ });
+ }
+ });
+
+ it('EDGES — 16개, source/target이 모두 NODE_IDS 안에 존재', () => {
+ expect(EDGES).toHaveLength(16);
+ const validIds = new Set(Object.values(NODE_IDS));
+ for (const e of EDGES) {
+ expect(validIds.has(e.source)).toBe(true);
+ expect(validIds.has(e.target)).toBe(true);
+ expect(e.id).toBeTruthy();
+ }
+ });
+
+ it('EDGES — 7개 점수 노드는 모두 gate 입력 + combine 출력을 가짐', () => {
+ const SCORE_IDS = [
+ NODE_IDS.FOREIGN, NODE_IDS.VOLUME, NODE_IDS.MOMENTUM,
+ NODE_IDS.HIGH52W, NODE_IDS.RS, NODE_IDS.MA, NODE_IDS.VCP,
+ ];
+ for (const sid of SCORE_IDS) {
+ const hasGateInput = EDGES.some(
+ (e) => e.source === NODE_IDS.GATE && e.target === sid
+ );
+ const hasCombineOutput = EDGES.some(
+ (e) => e.source === sid && e.target === NODE_IDS.COMBINE
+ );
+ expect(hasGateInput).toBe(true);
+ expect(hasCombineOutput).toBe(true);
+ }
+ });
+
+ it('NODE_KIND_MAP — 각 노드의 kind ∈ {data,gate,score,combine,result}', () => {
+ const valid = new Set(['data','gate','score','combine','result']);
+ for (const id of Object.values(NODE_IDS)) {
+ expect(valid.has(NODE_KIND_MAP[id])).toBe(true);
+ }
+ });
+
+ it('SCORE_NODE_NAME_MAP — 7개 점수 노드 ID → backend node name', () => {
+ expect(Object.keys(SCORE_NODE_NAME_MAP)).toHaveLength(7);
+ expect(SCORE_NODE_NAME_MAP[NODE_IDS.FOREIGN]).toBe('foreign_buy');
+ expect(SCORE_NODE_NAME_MAP[NODE_IDS.VOLUME]).toBe('volume_surge');
+ });
+});
+```
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+```bash
+npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
+```
+Expected: FAIL — "Cannot find module './canvasLayout'".
+
+- [ ] **Step 3: `canvasLayout.js` 구현**
+
+```js
+export const NODE_IDS = {
+ DATA: 'data',
+ GATE: 'gate-hygiene',
+ FOREIGN: 'score-foreign-buy',
+ VOLUME: 'score-volume-surge',
+ MOMENTUM: 'score-momentum',
+ HIGH52W: 'score-high52w',
+ RS: 'score-rs-rating',
+ MA: 'score-ma-alignment',
+ VCP: 'score-vcp-lite',
+ COMBINE: 'combine',
+ RESULT: 'result',
+};
+
+export const NODE_KIND_MAP = {
+ [NODE_IDS.DATA]: 'data',
+ [NODE_IDS.GATE]: 'gate',
+ [NODE_IDS.FOREIGN]: 'score',
+ [NODE_IDS.VOLUME]: 'score',
+ [NODE_IDS.MOMENTUM]: 'score',
+ [NODE_IDS.HIGH52W]: 'score',
+ [NODE_IDS.RS]: 'score',
+ [NODE_IDS.MA]: 'score',
+ [NODE_IDS.VCP]: 'score',
+ [NODE_IDS.COMBINE]: 'combine',
+ [NODE_IDS.RESULT]: 'result',
+};
+
+// 캔버스 노드 ID → 백엔드 score node name (registry 키)
+export const SCORE_NODE_NAME_MAP = {
+ [NODE_IDS.FOREIGN]: 'foreign_buy',
+ [NODE_IDS.VOLUME]: 'volume_surge',
+ [NODE_IDS.MOMENTUM]: 'momentum',
+ [NODE_IDS.HIGH52W]: 'high52w',
+ [NODE_IDS.RS]: 'rs_rating',
+ [NODE_IDS.MA]: 'ma_alignment',
+ [NODE_IDS.VCP]: 'vcp_lite',
+};
+
+// 4단 layout: DATA → GATE → (점수 7개 세로) → COMBINE → RESULT
+export const INITIAL_NODE_POSITIONS = {
+ [NODE_IDS.DATA]: { x: 40, y: 280 },
+ [NODE_IDS.GATE]: { x: 240, y: 280 },
+ [NODE_IDS.FOREIGN]: { x: 480, y: 0 },
+ [NODE_IDS.VOLUME]: { x: 480, y: 90 },
+ [NODE_IDS.MOMENTUM]: { x: 480, y: 180 },
+ [NODE_IDS.HIGH52W]: { x: 480, y: 270 },
+ [NODE_IDS.RS]: { x: 480, y: 360 },
+ [NODE_IDS.MA]: { x: 480, y: 450 },
+ [NODE_IDS.VCP]: { x: 480, y: 540 },
+ [NODE_IDS.COMBINE]: { x: 800, y: 280 },
+ [NODE_IDS.RESULT]: { x: 1080, y: 280 },
+};
+
+const SCORE_KEYS = ['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'];
+
+export const EDGES = [
+ { id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE },
+ ...SCORE_KEYS.map((k) => ({
+ id: `e-gate-${k.toLowerCase()}`,
+ source: NODE_IDS.GATE,
+ target: NODE_IDS[k],
+ })),
+ ...SCORE_KEYS.map((k) => ({
+ id: `e-${k.toLowerCase()}-combine`,
+ source: NODE_IDS[k],
+ target: NODE_IDS.COMBINE,
+ })),
+ { id: 'e-combine-result', source: NODE_IDS.COMBINE, target: NODE_IDS.RESULT },
+];
+
+export const SCORE_NODE_LABEL = {
+ [NODE_IDS.FOREIGN]: { icon: '🌏', title: '외국인 매수' },
+ [NODE_IDS.VOLUME]: { icon: '📊', title: '거래량 급증' },
+ [NODE_IDS.MOMENTUM]: { icon: '🚀', title: '모멘텀' },
+ [NODE_IDS.HIGH52W]: { icon: '🔝', title: '52주 고가' },
+ [NODE_IDS.RS]: { icon: '💪', title: 'RS Rating' },
+ [NODE_IDS.MA]: { icon: '📈', title: '이평선 정렬' },
+ [NODE_IDS.VCP]: { icon: '🌀', title: 'VCP-lite' },
+};
+```
+
+- [ ] **Step 4: 테스트 통과 확인**
+
+```bash
+npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
+```
+Expected: PASS — 6 tests passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/pages/stock/screener/components/canvas/constants/
+git commit -m "feat(screener): add canvas layout constants (11 nodes, 16 edges)"
+```
+
+---
+
+### Task 3: `useScreenerMode` hook + 테스트
+
+**Files:**
+- Create: `src/pages/stock/screener/hooks/useScreenerMode.js`
+- Test: `src/pages/stock/screener/hooks/useScreenerMode.test.js`
+
+- [ ] **Step 1: 실패하는 테스트 작성**
+
+```js
+import { describe, it, expect } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useScreenerMode } from './useScreenerMode';
+
+describe('useScreenerMode', () => {
+ it('초기값은 "form"', () => {
+ const { result } = renderHook(() => useScreenerMode());
+ expect(result.current.mode).toBe('form');
+ });
+
+ it('localStorage 에 저장된 값 복원', () => {
+ localStorage.setItem('screener-mode-v1', 'canvas');
+ const { result } = renderHook(() => useScreenerMode());
+ expect(result.current.mode).toBe('canvas');
+ });
+
+ it('손상된 localStorage 는 "form" 으로 fallback', () => {
+ localStorage.setItem('screener-mode-v1', 'INVALID_MODE');
+ const { result } = renderHook(() => useScreenerMode());
+ expect(result.current.mode).toBe('form');
+ });
+
+ it('setMode 호출 시 state 와 localStorage 모두 갱신', () => {
+ const { result } = renderHook(() => useScreenerMode());
+ act(() => result.current.setMode('canvas'));
+ expect(result.current.mode).toBe('canvas');
+ expect(localStorage.getItem('screener-mode-v1')).toBe('canvas');
+ });
+});
+```
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+```bash
+npx vitest run src/pages/stock/screener/hooks/useScreenerMode.test.js
+```
+Expected: FAIL — "Cannot find module './useScreenerMode'".
+
+- [ ] **Step 3: `useScreenerMode.js` 구현**
+
+```js
+import { useState } from 'react';
+
+const STORAGE_KEY = 'screener-mode-v1';
+const VALID_MODES = new Set(['form', 'canvas']);
+
+function readMode() {
+ try {
+ const v = localStorage.getItem(STORAGE_KEY);
+ return VALID_MODES.has(v) ? v : 'form';
+ } catch {
+ return 'form';
+ }
+}
+
+export function useScreenerMode() {
+ const [mode, setModeState] = useState(readMode);
+
+ const setMode = (m) => {
+ if (!VALID_MODES.has(m)) return;
+ setModeState(m);
+ try { localStorage.setItem(STORAGE_KEY, m); } catch {}
+ };
+
+ return { mode, setMode };
+}
+```
+
+- [ ] **Step 4: 테스트 통과 확인**
+
+```bash
+npx vitest run src/pages/stock/screener/hooks/useScreenerMode.test.js
+```
+Expected: PASS — 4 tests passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/pages/stock/screener/hooks/useScreenerMode.js src/pages/stock/screener/hooks/useScreenerMode.test.js
+git commit -m "feat(screener): useScreenerMode hook (form|canvas + localStorage)"
+```
+
+---
+
+### Task 4: `useCanvasLayout` hook + 테스트
+
+**Files:**
+- Create: `src/pages/stock/screener/hooks/useCanvasLayout.js`
+- Test: `src/pages/stock/screener/hooks/useCanvasLayout.test.js`
+
+- [ ] **Step 1: 실패하는 테스트 작성**
+
+```js
+import { describe, it, expect } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useCanvasLayout } from './useCanvasLayout';
+
+const INITIAL = {
+ a: { x: 0, y: 0 },
+ b: { x: 100, y: 100 },
+ c: { x: 200, y: 200 },
+};
+
+describe('useCanvasLayout', () => {
+ it('초기 호출 시 INITIAL 반환', () => {
+ const { result } = renderHook(() => useCanvasLayout(INITIAL));
+ expect(result.current.positions).toEqual(INITIAL);
+ });
+
+ it('updateNodePosition 호출 시 state + localStorage 모두 갱신', () => {
+ const { result } = renderHook(() => useCanvasLayout(INITIAL));
+ act(() => result.current.updateNodePosition('a', { x: 50, y: 50 }));
+ expect(result.current.positions.a).toEqual({ x: 50, y: 50 });
+ const stored = JSON.parse(localStorage.getItem('screener-canvas-layout-v1'));
+ expect(stored.a).toEqual({ x: 50, y: 50 });
+ });
+
+ it('reset 호출 시 INITIAL 복원 + localStorage 삭제', () => {
+ const { result } = renderHook(() => useCanvasLayout(INITIAL));
+ act(() => result.current.updateNodePosition('a', { x: 50, y: 50 }));
+ act(() => result.current.reset());
+ expect(result.current.positions).toEqual(INITIAL);
+ expect(localStorage.getItem('screener-canvas-layout-v1')).toBeNull();
+ });
+
+ it('손상된 localStorage 는 INITIAL 로 fallback', () => {
+ localStorage.setItem('screener-canvas-layout-v1', 'NOT_JSON');
+ const { result } = renderHook(() => useCanvasLayout(INITIAL));
+ expect(result.current.positions).toEqual(INITIAL);
+ });
+
+ it('localStorage 에 일부 ID 만 있으면 누락 ID 는 INITIAL 보충', () => {
+ localStorage.setItem(
+ 'screener-canvas-layout-v1',
+ JSON.stringify({ a: { x: 999, y: 999 } })
+ );
+ const { result } = renderHook(() => useCanvasLayout(INITIAL));
+ expect(result.current.positions.a).toEqual({ x: 999, y: 999 });
+ expect(result.current.positions.b).toEqual({ x: 100, y: 100 });
+ expect(result.current.positions.c).toEqual({ x: 200, y: 200 });
+ });
+});
+```
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+```bash
+npx vitest run src/pages/stock/screener/hooks/useCanvasLayout.test.js
+```
+Expected: FAIL — "Cannot find module './useCanvasLayout'".
+
+- [ ] **Step 3: `useCanvasLayout.js` 구현**
+
+```js
+import { useState, useCallback } from 'react';
+
+const STORAGE_KEY = 'screener-canvas-layout-v1';
+
+function readPositions(initial) {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return initial;
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object') return initial;
+ // 누락 ID 보충
+ return { ...initial, ...filterValidEntries(parsed) };
+ } catch {
+ return initial;
+ }
+}
+
+function filterValidEntries(obj) {
+ const out = {};
+ for (const [k, v] of Object.entries(obj)) {
+ if (v && typeof v.x === 'number' && typeof v.y === 'number') {
+ out[k] = { x: v.x, y: v.y };
+ }
+ }
+ return out;
+}
+
+export function useCanvasLayout(initialPositions) {
+ const [positions, setPositions] = useState(() => readPositions(initialPositions));
+
+ const updateNodePosition = useCallback((nodeId, pos) => {
+ setPositions((prev) => {
+ const next = { ...prev, [nodeId]: { x: pos.x, y: pos.y } };
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
+ return next;
+ });
+ }, []);
+
+ const reset = useCallback(() => {
+ setPositions(initialPositions);
+ try { localStorage.removeItem(STORAGE_KEY); } catch {}
+ }, [initialPositions]);
+
+ return { positions, updateNodePosition, reset };
+}
+```
+
+- [ ] **Step 4: 테스트 통과 확인**
+
+```bash
+npx vitest run src/pages/stock/screener/hooks/useCanvasLayout.test.js
+```
+Expected: PASS — 5 tests passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/pages/stock/screener/hooks/useCanvasLayout.js src/pages/stock/screener/hooks/useCanvasLayout.test.js
+git commit -m "feat(screener): useCanvasLayout hook (node positions + reset)"
+```
+
+---
+
+### Task 5: `ModeToggle` 컴포넌트
+
+**Files:**
+- Create: `src/pages/stock/screener/components/ModeToggle.jsx`
+
+- [ ] **Step 1: `ModeToggle.jsx` 구현**
+
+```jsx
+import React from 'react';
+
+export default function ModeToggle({ value, onChange }) {
+ return (
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 2: 빠른 sanity import 검증**
+
+`vite` dev 서버는 아직 띄우지 않음. lint 만:
+```bash
+npx eslint src/pages/stock/screener/components/ModeToggle.jsx
+```
+Expected: 0 errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/pages/stock/screener/components/ModeToggle.jsx
+git commit -m "feat(screener): ModeToggle segment control component"
+```
+
+---
+
+### Task 6: `FixedNodeCard` (데이터/결합/결과 노드)
+
+**Files:**
+- Create: `src/pages/stock/screener/components/canvas/nodes/FixedNodeCard.jsx`
+
+- [ ] **Step 1: `FixedNodeCard.jsx` 구현**
+
+```jsx
+import React, { memo } from 'react';
+import { Handle, Position } from '@xyflow/react';
+
+function FixedNodeCard({ data }) {
+ const { icon, title, subtitle, kind } = data;
+ const hasInput = kind !== 'data';
+ const hasOutput = kind !== 'result';
+
+ return (
+
+ {hasInput &&
}
+
+ {icon}
+ {title}
+
+ {subtitle &&
{subtitle}
}
+ {hasOutput &&
}
+
+ );
+}
+
+export default memo(FixedNodeCard);
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/pages/stock/screener/components/canvas/nodes/FixedNodeCard.jsx
+git commit -m "feat(screener): FixedNodeCard for data/combine/result nodes"
+```
+
+---
+
+### Task 7: `GateNodeCard` (위생 게이트)
+
+**Files:**
+- Create: `src/pages/stock/screener/components/canvas/nodes/GateNodeCard.jsx`
+
+- [ ] **Step 1: `GateNodeCard.jsx` 구현**
+
+```jsx
+import React, { memo, useState } from 'react';
+import { Handle, Position } from '@xyflow/react';
+
+function ParamField({ name, schema, value, onChange }) {
+ if (schema?.type === 'boolean') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+function GateNodeCard({ data }) {
+ const { meta, params, onChange, description } = data;
+ const [expanded, setExpanded] = useState(false);
+
+ const update = (key, v) => onChange({ ...params, [key]: v });
+
+ return (
+
+
+
+ 🛡️
+ {meta?.label || '위생 게이트'}
+ {description && (
+ ⓘ
+ )}
+
+
통과해야 점수 단계 진입
+
+ {expanded && (
+
+ {Object.entries(meta?.param_schema || {}).map(([key, schema]) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+export default memo(GateNodeCard);
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/pages/stock/screener/components/canvas/nodes/GateNodeCard.jsx
+git commit -m "feat(screener): GateNodeCard for hygiene gate"
+```
+
+---
+
+### Task 8: `ScoreNodeCard` 컴포넌트 + 테스트
+
+**Files:**
+- Create: `src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx`
+- Test: `src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx`
+
+- [ ] **Step 1: 실패하는 테스트 작성**
+
+```jsx
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ReactFlowProvider } from '@xyflow/react';
+import ScoreNodeCard from './ScoreNodeCard';
+
+const baseData = {
+ meta: {
+ name: 'volume_surge',
+ label: '거래량 급증',
+ param_schema: {
+ lookback_days: { type: 'integer', default: 20, label: 'lookback' },
+ multiplier: { type: 'number', default: 2.0, step: 0.1, label: 'mult' },
+ },
+ },
+ weight: 0.5,
+ params: { lookback_days: 20, multiplier: 2.0 },
+ summary: '20일 평균 대비 2배 이상',
+ description: '거래량이 평균 대비 급증한 종목을 가산',
+ accent: '#3b82f6',
+ onWeightChange: vi.fn(),
+ onParamsChange: vi.fn(),
+};
+
+function renderInFlow(data) {
+ return render(
+
+
+
+ );
+}
+
+describe('ScoreNodeCard', () => {
+ it('타이틀과 한 줄 요약을 표시한다', () => {
+ renderInFlow(baseData);
+ expect(screen.getByText('거래량 급증')).toBeInTheDocument();
+ expect(screen.getByText('20일 평균 대비 2배 이상')).toBeInTheDocument();
+ });
+
+ it('가중치 슬라이더 변경 시 onWeightChange 호출', () => {
+ const onWeightChange = vi.fn();
+ renderInFlow({ ...baseData, onWeightChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.change(slider, { target: { value: '0.8' } });
+ expect(onWeightChange).toHaveBeenCalledWith(0.8);
+ });
+
+ it('활성 체크박스 uncheck 시 onWeightChange(0)', () => {
+ const onWeightChange = vi.fn();
+ renderInFlow({ ...baseData, weight: 0.5, onWeightChange });
+ const checkbox = screen.getByRole('checkbox', { name: /활성/ });
+ expect(checkbox).toBeChecked();
+ fireEvent.click(checkbox);
+ expect(onWeightChange).toHaveBeenCalledWith(0);
+ });
+
+ it('weight=0 상태에서 활성 체크 시 기본값 0.5로 복원', () => {
+ const onWeightChange = vi.fn();
+ renderInFlow({ ...baseData, weight: 0, onWeightChange });
+ const checkbox = screen.getByRole('checkbox', { name: /활성/ });
+ expect(checkbox).not.toBeChecked();
+ fireEvent.click(checkbox);
+ expect(onWeightChange).toHaveBeenCalledWith(0.5);
+ });
+
+ it('파라미터 펼치기 토글', () => {
+ renderInFlow(baseData);
+ expect(screen.queryByLabelText('lookback')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /파라미터/ }));
+ expect(screen.getByLabelText('lookback')).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+```bash
+npx vitest run src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx
+```
+Expected: FAIL — "Cannot find module './ScoreNodeCard'".
+
+- [ ] **Step 3: `ScoreNodeCard.jsx` 구현**
+
+```jsx
+import React, { memo, useState } from 'react';
+import { Handle, Position } from '@xyflow/react';
+
+const DEFAULT_WEIGHT = 0.5;
+
+function ParamField({ name, schema, value, onChange }) {
+ return (
+
+ );
+}
+
+function ScoreNodeCard({ data }) {
+ const {
+ meta, weight, params, summary, description, accent, icon,
+ onWeightChange, onParamsChange,
+ } = data;
+ const [expanded, setExpanded] = useState(false);
+
+ const active = weight > 0;
+
+ const toggleActive = () => {
+ if (active) onWeightChange(0);
+ else onWeightChange(DEFAULT_WEIGHT);
+ };
+
+ const updateParam = (key, v) =>
+ onParamsChange({ ...params, [key]: v });
+
+ return (
+
+
+
+ {icon}
+ {meta?.label || meta?.name}
+ {description && (
+ ⓘ
+ )}
+
+ {summary &&
{summary}
}
+
+ onWeightChange(Number(e.target.value))}
+ aria-label="가중치"
+ />
+ {weight.toFixed(2)}
+
+
+
+ {expanded && (
+
+ {Object.entries(meta?.param_schema || {}).map(([key, schema]) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+export default memo(ScoreNodeCard);
+```
+
+- [ ] **Step 4: 테스트 통과 확인**
+
+```bash
+npx vitest run src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx
+```
+Expected: PASS — 5 tests passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx
+git commit -m "feat(screener): ScoreNodeCard with weight slider + active toggle + params"
+```
+
+---
+
+### Task 9: `CanvasToolbar` (floating Panel)
+
+**Files:**
+- Create: `src/pages/stock/screener/components/canvas/CanvasToolbar.jsx`
+
+- [ ] **Step 1: `CanvasToolbar.jsx` 구현**
+
+```jsx
+import React from 'react';
+import { Panel, useReactFlow } from '@xyflow/react';
+
+export default function CanvasToolbar({
+ onRunPreview,
+ onRunSave,
+ onPersistSettings,
+ onResetLayout,
+ dirty,
+ running,
+}) {
+ const { fitView } = useReactFlow();
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/pages/stock/screener/components/canvas/CanvasToolbar.jsx
+git commit -m "feat(screener): CanvasToolbar floating panel"
+```
+
+---
+
+### Task 10: `ScreenerCanvas` (React Flow root)
+
+**Files:**
+- Create: `src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx`
+
+- [ ] **Step 1: `ScreenerCanvas.jsx` 구현**
+
+```jsx
+import React, { useMemo, useCallback } from 'react';
+import {
+ ReactFlow, Background, Controls, ReactFlowProvider,
+} from '@xyflow/react';
+import '@xyflow/react/dist/style.css';
+
+import {
+ NODE_IDS, NODE_KIND_MAP, SCORE_NODE_NAME_MAP,
+ EDGES, SCORE_NODE_LABEL, INITIAL_NODE_POSITIONS,
+} from './constants/canvasLayout';
+import { useCanvasLayout } from '../../hooks/useCanvasLayout';
+
+import ScoreNodeCard from './nodes/ScoreNodeCard';
+import GateNodeCard from './nodes/GateNodeCard';
+import FixedNodeCard from './nodes/FixedNodeCard';
+import CanvasToolbar from './CanvasToolbar';
+
+const nodeTypes = {
+ score: ScoreNodeCard,
+ gate: GateNodeCard,
+ fixed: FixedNodeCard,
+};
+
+function activeAccent(weight, baseAccent) {
+ if (weight > 0) return baseAccent;
+ return '#374151';
+}
+
+function buildEdges(weights) {
+ return EDGES.map((e) => {
+ const targetKind = NODE_KIND_MAP[e.target];
+ const sourceKind = NODE_KIND_MAP[e.source];
+ // gate → 점수: 해당 점수 노드 weight 가 활성인지에 따라 stroke
+ let active = true;
+ if (sourceKind === 'gate' && targetKind === 'score') {
+ const nodeName = SCORE_NODE_NAME_MAP[e.target];
+ active = (weights?.[nodeName] ?? 0) > 0;
+ } else if (sourceKind === 'score' && targetKind === 'combine') {
+ const nodeName = SCORE_NODE_NAME_MAP[e.source];
+ active = (weights?.[nodeName] ?? 0) > 0;
+ }
+ return {
+ ...e,
+ animated: active,
+ style: {
+ stroke: active ? '#fbbf24' : '#374151',
+ strokeWidth: active ? 1.5 : 1,
+ strokeDasharray: active ? undefined : '4 4',
+ },
+ };
+ });
+}
+
+function ScreenerCanvasInner({
+ meta, settings, setLocal, result, running, dirty,
+ onRunPreview, onRunSave, onPersistSettings,
+}) {
+ const { positions, updateNodePosition, reset } = useCanvasLayout(INITIAL_NODE_POSITIONS);
+
+ const onWeightChange = useCallback((nodeId, weight) => {
+ const name = SCORE_NODE_NAME_MAP[nodeId];
+ if (!name) return;
+ setLocal({ ...settings, weights: { ...settings.weights, [name]: weight } });
+ }, [settings, setLocal]);
+
+ const onParamsChange = useCallback((nodeId, params) => {
+ const name = SCORE_NODE_NAME_MAP[nodeId];
+ if (!name) return;
+ setLocal({ ...settings, node_params: { ...settings.node_params, [name]: params } });
+ }, [settings, setLocal]);
+
+ const onGateParamsChange = useCallback((params) => {
+ setLocal({ ...settings, gate_params: params });
+ }, [settings, setLocal]);
+
+ const scoreMetaByName = useMemo(() => {
+ const map = {};
+ for (const m of meta?.score_nodes ?? []) map[m.name] = m;
+ return map;
+ }, [meta]);
+
+ const gateMeta = meta?.gate_nodes?.[0];
+
+ const nodes = useMemo(() => {
+ const arr = [];
+ arr.push({
+ id: NODE_IDS.DATA,
+ type: 'fixed',
+ position: positions[NODE_IDS.DATA],
+ data: { icon: '📥', title: 'KRX 데이터', subtitle: '~2,800종목 · FDR', kind: 'data' },
+ draggable: true,
+ });
+ arr.push({
+ id: NODE_IDS.GATE,
+ type: 'gate',
+ position: positions[NODE_IDS.GATE],
+ data: {
+ meta: gateMeta,
+ params: settings.gate_params,
+ description: gateMeta?.label || '위생 게이트',
+ onChange: onGateParamsChange,
+ },
+ draggable: true,
+ });
+ for (const [nodeId, backendName] of Object.entries(SCORE_NODE_NAME_MAP)) {
+ const m = scoreMetaByName[backendName];
+ const label = SCORE_NODE_LABEL[nodeId] || { icon: '📈', title: backendName };
+ arr.push({
+ id: nodeId,
+ type: 'score',
+ position: positions[nodeId],
+ data: {
+ meta: m ? { ...m, label: label.title } : { name: backendName, label: label.title },
+ weight: settings.weights?.[backendName] ?? 0,
+ params: settings.node_params?.[backendName] ?? {},
+ summary: m?.summary || '',
+ description: m?.description || m?.label || '',
+ accent: m?.color || '#3b82f6',
+ icon: label.icon,
+ onWeightChange: (w) => onWeightChange(nodeId, w),
+ onParamsChange: (p) => onParamsChange(nodeId, p),
+ },
+ draggable: true,
+ });
+ }
+ const tp = settings.top_n;
+ const rr = settings.rr_ratio;
+ const am = settings.atr_stop_mult;
+ arr.push({
+ id: NODE_IDS.COMBINE,
+ type: 'fixed',
+ position: positions[NODE_IDS.COMBINE],
+ data: {
+ icon: '⚙️',
+ title: '가중합 + TopN + ATR',
+ subtitle: `Top ${tp} · RR ${rr} · ATR×${am}`,
+ kind: 'combine',
+ },
+ draggable: true,
+ });
+ const survivors = result?.survivors_count;
+ const asof = result?.asof;
+ arr.push({
+ id: NODE_IDS.RESULT,
+ type: 'fixed',
+ position: positions[NODE_IDS.RESULT],
+ data: {
+ icon: '📊',
+ title: '결과',
+ subtitle: asof ? `${asof} · ${survivors ?? '-'}종목 통과` : '아직 실행 안 됨',
+ kind: 'result',
+ },
+ draggable: true,
+ });
+ return arr;
+ }, [positions, settings, meta, scoreMetaByName, gateMeta,
+ onWeightChange, onParamsChange, onGateParamsChange, result]);
+
+ const edges = useMemo(() => buildEdges(settings.weights), [settings.weights]);
+
+ const handleNodeDragStop = useCallback((_evt, node) => {
+ updateNodePosition(node.id, node.position);
+ }, [updateNodePosition]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default function ScreenerCanvas(props) {
+ return (
+
+
+
+ );
+}
+```
+
+- [ ] **Step 2: lint 통과 확인**
+
+```bash
+npx eslint src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
+```
+Expected: 0 errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
+git commit -m "feat(screener): ScreenerCanvas root component (react-flow + 11 nodes + 16 edges)"
+```
+
+---
+
+### Task 11: `CanvasLayout` (캔버스 + 결과 영역 그리드)
+
+**Files:**
+- Create: `src/pages/stock/screener/components/canvas/CanvasLayout.jsx`
+
+- [ ] **Step 1: `CanvasLayout.jsx` 구현**
+
+```jsx
+import React from 'react';
+import ScreenerCanvas from './ScreenerCanvas';
+import ResultTable from '../ResultTable';
+import TelegramPreview from '../TelegramPreview';
+import RunHistoryList from '../RunHistoryList';
+
+export default function CanvasLayout({
+ meta, settings, setLocal, save, dirty,
+ result, running, previewHistory, runPreview, runSave, selectPreview,
+ runs, runs_loading, selectRun, selectedRun,
+ compareId, setCompareId,
+}) {
+ const compareItem = previewHistory.find((p) => p.id === compareId);
+ const compareResult = compareItem?.result ?? null;
+ const activeResult = selectedRun || result;
+
+ return (
+
+
+ runPreview(settings)}
+ onRunSave={() => runSave(settings)}
+ onPersistSettings={save}
+ />
+
+
+
+ );
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/pages/stock/screener/components/canvas/CanvasLayout.jsx
+git commit -m "feat(screener): CanvasLayout (canvas + result grid)"
+```
+
+---
+
+### Task 12: `Screener.jsx` 모드 분기 통합
+
+**Files:**
+- Modify: `src/pages/stock/screener/Screener.jsx`
+
+- [ ] **Step 1: 기존 코드 전체 교체**
+
+```jsx
+import React, { useState, lazy, Suspense } from 'react';
+import { Link } from 'react-router-dom';
+import './Screener.css';
+
+import { useScreenerMeta } from './hooks/useScreenerMeta';
+import { useScreenerSettings } from './hooks/useScreenerSettings';
+import { useScreenerRun } from './hooks/useScreenerRun';
+import { useScreenerHistory } from './hooks/useScreenerHistory';
+import { useScreenerMode } from './hooks/useScreenerMode';
+import { useIsMobile } from '../../../hooks/useIsMobile';
+
+import GatePanel from './components/GatePanel';
+import NodePanel from './components/NodePanel';
+import GlobalControls from './components/GlobalControls';
+import ResultTable from './components/ResultTable';
+import TelegramPreview from './components/TelegramPreview';
+import RunHistoryList from './components/RunHistoryList';
+import ModeToggle from './components/ModeToggle';
+
+const CanvasLayout = lazy(() => import('./components/canvas/CanvasLayout'));
+
+export default function Screener() {
+ const { meta, loading: metaLoading } = useScreenerMeta();
+ const { settings, dirty, setLocal, save } = useScreenerSettings();
+ const { result, running, previewHistory, runPreview, runSave, selectPreview } = useScreenerRun();
+ const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory();
+ const { mode, setMode } = useScreenerMode();
+ const isMobile = useIsMobile();
+ const effectiveMode = isMobile ? 'form' : mode;
+
+ const [compareId, setCompareId] = useState(null);
+ const compareItem = previewHistory.find((p) => p.id === compareId);
+ const compareResult = compareItem?.result ?? null;
+ const activeResult = selectedRun || result;
+
+ if (metaLoading || !meta || !settings) {
+ return 로딩 중…
;
+ }
+
+ return (
+
+
+
+ {effectiveMode === 'form' ? (
+
+
+
+
+
+
+
+
+ ) : (
+
캔버스 로딩 중… }>
+
+
+ )}
+
+ );
+}
+```
+
+- [ ] **Step 2: lint 통과 확인**
+
+```bash
+npx eslint src/pages/stock/screener/Screener.jsx
+```
+Expected: 0 errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/pages/stock/screener/Screener.jsx
+git commit -m "feat(screener): integrate mode toggle (form|canvas) with lazy canvas"
+```
+
+---
+
+### Task 13: 캔버스 CSS
+
+**Files:**
+- Create: `src/pages/stock/screener/components/canvas/Canvas.css`
+- Modify: `src/pages/stock/screener/Screener.css`
+
+- [ ] **Step 1: `Canvas.css` 작성**
+
+```css
+/* ─────────── ModeToggle 헤더 컨트롤 ─────────── */
+.screener-mode-toggle {
+ display: inline-flex;
+ background: #111827;
+ border: 1px solid #1f2937;
+ border-radius: 8px;
+ overflow: hidden;
+}
+.screener-mode-toggle button {
+ padding: 6px 14px;
+ background: transparent;
+ color: #9ca3af;
+ border: 0;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+.screener-mode-toggle button.active {
+ background: #fbbf24;
+ color: #111827;
+ font-weight: 600;
+}
+
+.screener-header-right {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+/* ─────────── CanvasLayout 그리드 ─────────── */
+.screener-canvas-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.screener-canvas-area {
+ height: 65vh;
+ min-height: 480px;
+ border: 1px solid #1f2937;
+ border-radius: 12px;
+ overflow: hidden;
+ background: #0b1220;
+}
+.screener-canvas-results {
+ display: grid;
+ grid-template-columns: 1fr 300px;
+ gap: 16px;
+}
+.screener-canvas-results-main { display: flex; flex-direction: column; gap: 12px; }
+.screener-canvas-results-side { min-width: 0; }
+
+/* ─────────── React Flow 내부 ─────────── */
+.screener-canvas-wrap { width: 100%; height: 100%; }
+
+/* ─────────── 노드 카드 공통 ─────────── */
+.canvas-node {
+ background: #111827;
+ border: 1px solid #1f2937;
+ border-radius: 10px;
+ color: #e5e7eb;
+ font-size: 12px;
+ padding: 10px 12px;
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
+}
+.canvas-node-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ font-size: 13px;
+}
+.canvas-node-icon { font-size: 14px; }
+.canvas-node-info {
+ margin-left: auto;
+ color: #9ca3af;
+ cursor: help;
+}
+.canvas-node-subtitle,
+.canvas-node-summary {
+ color: #9ca3af;
+ font-size: 11px;
+ margin-top: 4px;
+}
+
+/* ─────────── 고정 노드 (회색) ─────────── */
+.canvas-node--fixed { width: 200px; }
+.canvas-node--data { border-left: 3px solid #4b5563; }
+.canvas-node--combine { border-left: 3px solid #6b7280; }
+.canvas-node--result { border-left: 3px solid #6b7280; }
+
+/* ─────────── 게이트 노드 (노랑) ─────────── */
+.canvas-node--gate {
+ width: 220px;
+ border-left: 4px solid #facc15;
+}
+
+/* ─────────── 점수 노드 (accent) ─────────── */
+.canvas-node--score {
+ width: 240px;
+ border-left: 4px solid var(--canvas-accent, #3b82f6);
+}
+.canvas-node--score.is-inactive {
+ opacity: 0.45;
+ filter: grayscale(0.6);
+}
+.canvas-node-weight {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+}
+.canvas-node-weight input[type=range] { flex: 1; }
+.canvas-node-weight-value {
+ min-width: 32px;
+ text-align: right;
+ color: var(--canvas-accent, #3b82f6);
+ font-variant-numeric: tabular-nums;
+}
+.canvas-node-active {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 6px;
+ color: #d1d5db;
+}
+.canvas-node-expand {
+ width: 100%;
+ margin-top: 8px;
+ padding: 4px 0;
+ background: transparent;
+ color: #9ca3af;
+ border: 1px dashed #374151;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 11px;
+}
+.canvas-node-params {
+ margin-top: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.canvas-param-field {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ color: #d1d5db;
+ font-size: 11px;
+}
+.canvas-param-field input[type=number] {
+ width: 70px;
+ background: #0b1220;
+ color: #e5e7eb;
+ border: 1px solid #1f2937;
+ border-radius: 4px;
+ padding: 2px 6px;
+}
+
+/* ─────────── floating toolbar ─────────── */
+.canvas-toolbar {
+ display: flex;
+ gap: 6px;
+ padding: 6px;
+ background: rgba(17, 24, 39, 0.75);
+ backdrop-filter: blur(8px);
+ border: 1px solid #1f2937;
+ border-radius: 10px;
+}
+.canvas-toolbar-btn {
+ padding: 6px 12px;
+ background: #1f2937;
+ color: #e5e7eb;
+ border: 1px solid #374151;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 12px;
+}
+.canvas-toolbar-btn:hover:not(:disabled) {
+ background: #374151;
+}
+.canvas-toolbar-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+.canvas-toolbar-btn--primary {
+ background: #fbbf24;
+ color: #111827;
+ border-color: #fbbf24;
+ font-weight: 600;
+}
+.canvas-toolbar-btn--primary:hover:not(:disabled) { background: #f59e0b; }
+
+/* ─────────── 모바일 (캔버스는 숨겨지므로 ModeToggle만 영향) ─────────── */
+@media (max-width: 768px) {
+ .screener-canvas-results { grid-template-columns: 1fr; }
+}
+```
+
+- [ ] **Step 2: `Screener.css` 에 Canvas.css import 추가**
+
+`Screener.css` 첫 줄에 다음 추가:
+```css
+@import './components/canvas/Canvas.css';
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/pages/stock/screener/components/canvas/Canvas.css src/pages/stock/screener/Screener.css
+git commit -m "style(screener): canvas mode styles (toggle, nodes, toolbar, layout)"
+```
+
+---
+
+### Task 14: 전체 테스트 스위트 통과 확인
+
+**Files:**
+- (테스트만 실행)
+
+- [ ] **Step 1: 전체 테스트 실행**
+
+```bash
+cd C:\Users\jaeoh\Desktop\workspace\web-ui
+npx vitest run
+```
+Expected: 4개 테스트 파일, 총 20개 테스트 PASS.
+- `canvasLayout.test.js` — 6 tests
+- `useScreenerMode.test.js` — 4 tests
+- `useCanvasLayout.test.js` — 5 tests
+- `ScoreNodeCard.test.jsx` — 5 tests
+
+- [ ] **Step 2: 빌드 통과 확인**
+
+```bash
+npm run build
+```
+Expected: `dist/` 디렉토리 생성, 빌드 에러 없음. `@xyflow/react` 가 별도 chunk로 분리되었는지 확인 (lazy import 때문).
+
+- [ ] **Step 3: lint 통과 확인**
+
+```bash
+npm run lint
+```
+Expected: 0 errors. warning은 무시 가능.
+
+---
+
+### Task 15: 수동 검증 + 배포
+
+**Files:**
+- (실행만, 수동 점검)
+
+- [ ] **Step 1: dev 서버 실행**
+
+```bash
+cd C:\Users\jaeoh\Desktop\workspace\web-ui
+npm run dev
+```
+브라우저에서 `http://localhost:3007/stock/screener` 열기.
+
+- [ ] **Step 2: 폼 모드 회귀 검증**
+
+폼 모드(기본)에서:
+- [ ] 좌측 GatePanel, NodePanel, GlobalControls 정상 렌더
+- [ ] 가중치 슬라이더 변경 → dirty 표시
+- [ ] "지금 실행" → 결과 테이블 갱신
+- [ ] 모든 기존 기능 정상
+
+- [ ] **Step 3: 캔버스 모드 검증**
+
+헤더 우상단 [폼][캔버스] 토글에서 **캔버스** 클릭:
+- [ ] 캔버스에 11개 노드, 16개 엣지 사전 배치 표시
+- [ ] 점수 노드 카드: 가중치 슬라이더, 활성 체크, ▾ 파라미터 펼치기 동작
+- [ ] 점수 카드 변경 → 토글로 폼 모드 복귀 시 값 유지
+- [ ] 게이트 카드: ▾ 파라미터 펼치기, 값 변경 후 폼 모드 복귀 시 유지
+- [ ] 결합 카드 부제목에 `Top N · RR · ATR×M` 표시
+- [ ] 결과 카드 부제목에 마지막 실행 정보 표시
+- [ ] floating 툴바 5개 버튼 모두 클릭 동작
+- [ ] ▶ 실행 → 하단 ResultTable 갱신
+- [ ] 노드 드래그 후 새로고침 → 위치 복원
+- [ ] 🔄 → 초기 좌표 복귀
+- [ ] 점수 노드 weight=0 으로 만들면 카드 흐릿 + 해당 엣지 점선·회색
+- [ ] ⛶ → 화면 맞춤
+
+- [ ] **Step 4: 모바일 검증**
+
+DevTools → 모바일 뷰 (360×640):
+- [ ] 헤더에 토글 미표시
+- [ ] 폼 모드 모바일 카드 layout 정상
+- [ ] localStorage에 `canvas` 가 저장돼 있어도 강제로 폼 모드
+
+- [ ] **Step 5: localStorage 손상 복구 검증**
+
+DevTools Application 탭에서:
+- [ ] `screener-mode-v1` 을 `"INVALID"` 로 수정 → 새로고침 → 폼 모드로 fallback
+- [ ] `screener-canvas-layout-v1` 을 `"NOT_JSON"` 으로 수정 → 새로고침 → 캔버스 초기 좌표로 복귀
+
+- [ ] **Step 6: 배포**
+
+```bash
+cd C:\Users\jaeoh\Desktop\workspace\web-ui
+npm run release:nas
+```
+Expected: 빌드 + robocopy 성공. NAS에서 https://gahusb.synology.me/stock/screener 접속해 동작 확인.
+
+- [ ] **Step 7: CLAUDE.md 업데이트**
+
+`web-ui/CLAUDE.md` 의 페이지 구조 표에서 `/stock/screener` 행 설명을 다음으로 갱신:
+```
+| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
+```
+
+- [ ] **Step 8: 최종 commit + push**
+
+```bash
+git add src/pages/stock/screener/CLAUDE.md # if needed
+git add CLAUDE.md
+git commit -m "docs(screener): note canvas mode in page structure"
+git push origin main
+```
+
+---
+
+## 완료 후 후속 메모 업데이트 안내
+
+본 plan 완료 시 다음 메모리 파일을 업데이트 권장:
+- `project_stock_screener.md` — 캔버스 모드 완료 표시 + 후속 슬라이스에서 "노드 캔버스 UI" 항목 제거
+- 신규 reference: `reference_react_flow.md` — `@xyflow/react` 사용 패턴 (lazy import + jsdom polyfills + Panel 슬롯)
+
+다음 자연스러운 슬라이스 (spec §14):
+1. 주간 자가학습 (가중치 자동 조정 제안)
+2. 백테스트 화면
+3. AI 뉴스 호재/악재 노드