diff --git a/.codacy.yml b/.codacy.yml index 7c2e3961..ccdf68e9 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -37,5 +37,17 @@ engines: - "SecurityJs_prod.variable_assigned_to_object_injection_sink" - "ESLint9_xss_no-mixed-html" # False positive: encrypted export HTML is downloaded as a file, not executed in DOM - "ESLint9_@typescript-eslint_no-floating-promises" + # Legacy ESLint (v8) is the engine the cloud analysis actually runs — issues are + # reported with ESLint8_ pattern IDs, so suppressions must use the ESLint8_ prefix. + eslint-8: + enabled: true + exclude_paths: + - "**/__tests__/**" + - "**/*.test.*" + - "**/*.spec.*" + disable_rules: + - "ESLint8_security_detect-object-injection" # FP: indexing constant lookup tables (TRIZ_PARAMETERS, BUTTON_VARIANTS) with typed keys + - "ESLint8_xss_no-mixed-html" # FP: React JSX components and DOM-node refs, not raw HTML strings; encrypted export is downloaded as a file + - "ESLint8_@typescript-eslint_no-floating-promises" # handled in code via void + .catch; suppress engine noise opengrep: enabled: true diff --git a/.deepsource.toml b/.deepsource.toml index 7e5a305a..cab6d641 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -18,7 +18,7 @@ exclude_patterns = [ ] [[analyzers]] -name = "javascript-typescript" +name = "javascript" enabled = true [analyzers.meta] diff --git a/plans/112-codacy-repo-issues-remediation-2026-08-10.md b/plans/112-codacy-repo-issues-remediation-2026-08-10.md new file mode 100644 index 00000000..eaa813e1 --- /dev/null +++ b/plans/112-codacy-repo-issues-remediation-2026-08-10.md @@ -0,0 +1,84 @@ +# Plan 112 — Codacy Repo-Level Issue Remediation (2026-08-10) + +## Status +**DONE** — implementation complete in PR #628 (`fix/codacy-repo-issues-2026-08-10`). + +## Problem + +Codacy reported **28 repo-level issues** on `main` (8× detect-object-injection, 6× +xss/no-mixed-html, 5× no-unnecessary-condition, 3× Semgrep SSRF, 2× SC2015, 2× +confusing-void-expression, 1× floating-promises, 1× misused-promises). + +### Root cause (config mismatch) + +`.codacy.yml` disabled `ESLint9_`-prefixed rules under the `eslint-9` engine, but +Codacy's cloud analysis **actually runs the legacy ESLint (v8)** engine, which +reports `ESLint8_`-prefixed pattern IDs. Every suppression in `.codacy.yml` was +**silently ineffective**: + +``` +tools endpoint: ESLint9 enabled=False (config file: True) + ESLint enabled=True (config file: False) ← actually running +issues reported: ESLint8_* (never matched by ESLint9_* disable_rules) +``` + +## Fixes + +### Code fixes (9 real issues) + +| File | Fix | Issues | +|------|-----|--------| +| `triz-view.tsx` | Clipboard promise: `void ... .catch()` + try/catch guard | floating-promises, unnecessary-condition | +| `type-selector.tsx` | `options.item(nextIdx).focus()` (non-null per lib.dom, in-bounds by construction) | unnecessary-condition, object-injection | +| `command-palette.tsx` | `||=` on `Record` → `Map` grouping | unnecessary-condition | +| `shared-primitives.tsx` | `current[0]` → `current.at(0)` so `?? container` is type-honest | unnecessary-condition | +| `form.tsx` | Removed dead `if (!fieldContext)` guard (never null; dereferenced above) | unnecessary-condition | +| `use-mobile.ts` | Braces around void-returning arrow cleanup | confusing-void-expression | +| `encrypt-export-dialog.tsx` | `void handleExport(...)` | misused-promises | +| `export-format-grid.tsx` | Braces around void setter | confusing-void-expression | +| `self-fix-loop.sh` | SC2015: `A && B || C` → grouped braces (×2) | shellcheck SC2015 | + +### False positives suppressed (19) + +- **`.codacy.yml`**: added `eslint-8` engine section with `ESLint8_`-prefixed + `disable_rules` for `security/detect-object-injection` (8) and + `xss/no-mixed-html` (6) — all verified false positives: + - object-injection: indexing constant lookup tables (`TRIZ_PARAMETERS`, + `BUTTON_VARIANTS`) with typed keys, or DOM NodeList indexes + - xss/mixed-html: React JSX components (JSX ≠ raw HTML strings), DOM node + references (`.activeElement`, `cloneNode`), and file downloads (encrypted + export HTML is downloaded, never inserted into the DOM) +- **Inline `// nosemgrep` comments** on 3 guarded fetches (SSRF): all use + `validateOllamaUrl` (localhost-only) or protocol + `isPrivateIP` guards + before `fetch` — Semgrep can't see the interprocedural validation. + +## Validation + +- `tsc -p tsconfig.app.json --noEmit` ✅ +- ESLint on all changed files ✅ +- 95 tests across 8 affected suites ✅ +- `shellcheck scripts/self-fix-loop.sh` ✅ +- Codacy PR gate on #628: `isUpToStandards=True`, **0 new issues** ✅ + +## Dependencies / Follow-ups + +1. **DeepSource on #628** remains red until **PR #627 merges** — DeepSource + reads `.deepsource.toml` from `main`, which still has the invalid + `javascript-typescript` analyzer name. #627 fixes it to `javascript` + + `skip_doc_coverage`. After #627 merges, #628's DeepSource check will re-run + green (config is read from the base branch). +2. **Repo-level count stays at 28 until #628 merges** — Codacy also reads + `.codacy.yml` from `main`. After merge + reanalysis, repo issues drop to 0. +3. All 5 open PRs (#624–#628) remain `BLOCKED` by GitHub ruleset merge-state + staleness (see Plan 098) — the code_scanning tool-name trailing-space fix + was applied to ruleset 15161694, awaiting GitHub propagation. + +## Lessons + +- Codacy pattern IDs are engine-prefixed; **verify the actual engine** via the + tools endpoint before writing `disable_rules` — `ESLint9_` suppressions do + not match `ESLint8_`-prefixed findings from the legacy engine. +- `NodeListOf.item()` is typed **non-nullable** in lib.dom, so `?.` on it is a + real `no-unnecessary-condition` finding; bracket indexing triggers + `detect-object-injection`. `.item(i)` called directly is both type- and + runtime-correct when the index is proven in-bounds. diff --git a/scripts/self-fix-loop.sh b/scripts/self-fix-loop.sh index cc943955..649287ea 100755 --- a/scripts/self-fix-loop.sh +++ b/scripts/self-fix-loop.sh @@ -473,11 +473,11 @@ for c in json.load(sys.stdin): fix_applied=true elif echo "$logs" | grep -qiE "(link|broken.*reference|404)"; then info "Link/reference error detected — running validate-links..." - [ -f ./scripts/validate-links.sh ] && ./scripts/validate-links.sh 2>/dev/null || true + if [ -f ./scripts/validate-links.sh ]; then ./scripts/validate-links.sh 2>/dev/null || true; fi fix_applied=true elif echo "$logs" | grep -qiE "(skill|symlink)"; then info "Skill format issue detected — running validate-skills..." - [ -f ./scripts/validate-skills.sh ] && ./scripts/validate-skills.sh 2>/dev/null || true + if [ -f ./scripts/validate-skills.sh ]; then ./scripts/validate-skills.sh 2>/dev/null || true; fi fix_applied=true else warn "Unrecognized failure type in: ${failed_name}" diff --git a/src/components/studio/command-palette.tsx b/src/components/studio/command-palette.tsx index 983db27b..945c6183 100644 --- a/src/components/studio/command-palette.tsx +++ b/src/components/studio/command-palette.tsx @@ -34,7 +34,7 @@ interface CommandPaletteProps { } /** Command palette overlay for navigating views, creating entities, and searching the library. */ -export function CommandPalette({ onEntitySelect }: CommandPaletteProps) { +export const CommandPalette = ({ onEntitySelect }: CommandPaletteProps) => { const commandOpen = useStudioStore((s) => s.commandOpen) const setCommandOpen = useStudioStore((s) => s.setCommandOpen) const setView = useStudioStore((s) => s.setView) @@ -111,10 +111,12 @@ export function CommandPalette({ onEntitySelect }: CommandPaletteProps) { const allItems = [...navItems, ...libItems] const grouped = allItems.reduce( (acc, item) => { - ;(acc[item.group] ||= []).push(item) + const list = acc.get(item.group) ?? [] + list.push(item) + acc.set(item.group, list) return acc }, - {} as Record, + new Map(), ) if (!commandOpen) return null @@ -143,7 +145,7 @@ export function CommandPalette({ onEntitySelect }: CommandPaletteProps) { No matches. - {Object.entries(grouped).map(([group, items]) => + {[...grouped].map(([group, items]) => items.length ? ( void + /** Accessible label for the dialog */ + 'aria-label'?: string + /** ID of the element that labels the dialog */ + 'aria-labelledby'?: string + /** Visual variant */ + variant?: OverlayVariant + /** Whether clicking the backdrop closes the dialog */ + closeOnBackdrop?: boolean + /** Whether pressing Escape closes the dialog */ + closeOnEscape?: boolean + /** Whether to trap focus within the dialog */ + trapFocus?: boolean + /** Ref to the element that should receive initial focus */ + initialFocusRef?: React.RefObject + className?: string + children: React.ReactNode +} + +const VARIANT_CONTAINER: Record = { + center: + 'm-auto max-h-[calc(100dvh-2rem)] w-[min(100%-2rem,32rem)] overflow-y-auto rounded-xl', + 'sheet-bottom': + 'mx-auto mt-auto max-h-[calc(100dvh-2rem)] w-full max-w-lg overflow-y-auto rounded-t-xl', + 'sheet-left': + 'h-dvh w-[min(86vw,340px)] overflow-y-auto', + fullscreen: + 'h-full w-full overflow-y-auto', +} + +const getVariantClasses = (variant: OverlayVariant): string => { + switch (variant) { + case 'center': + return VARIANT_CONTAINER.center + case 'sheet-bottom': + return VARIANT_CONTAINER['sheet-bottom'] + case 'sheet-left': + return VARIANT_CONTAINER['sheet-left'] + case 'fullscreen': + return VARIANT_CONTAINER.fullscreen + } +} + +let scrollLockCount = 0 +let savedScrollbarWidth = 0 + +const lockBodyScroll = () => { + if (scrollLockCount === 0) { + savedScrollbarWidth = window.innerWidth - document.documentElement.clientWidth + document.body.style.overflow = 'hidden' + document.body.style.paddingRight = `${savedScrollbarWidth}px` + } + scrollLockCount++ +} + +const unlockBodyScroll = () => { + scrollLockCount-- + if (scrollLockCount === 0) { + document.body.style.overflow = '' + document.body.style.paddingRight = '' + } +} + +const useBodyScrollLock = (open: boolean) => { + useEffect(() => { + if (!open) return undefined + lockBodyScroll() + return unlockBodyScroll + }, [open]) +} + +const trapFocusWithinOverlay = (event: React.KeyboardEvent, focusable: HTMLElement[]) => { + const first = focusable.at(0) + const last = focusable.at(-1) + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last?.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first?.focus() + } +} + +const useOverlayFocus = ( + open: boolean, + initialFocusRef: React.RefObject | undefined, + containerRef: React.RefObject, +) => { + const previousFocusRef = useRef(null) + const focusableCacheRef = useRef([]) + + useEffect(() => { + if (open) { + const activeElement = document.activeElement + previousFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null + const container = containerRef.current + if (container) { + focusableCacheRef.current = Array.from( + container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ) + const target = initialFocusRef?.current ?? focusableCacheRef.current.at(0) + ;(target ?? container).focus() + } + return undefined + } + focusableCacheRef.current = [] + if (previousFocusRef.current) { + previousFocusRef.current.focus() + previousFocusRef.current = null + } + return undefined + }, [open, initialFocusRef, containerRef]) + + return focusableCacheRef +} + +/** Accessible modal overlay with focus trap, scroll lock, and configurable layout variant. */ +export const Overlay = ({ + open, + onClose, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + variant = 'center', + closeOnBackdrop = true, + closeOnEscape = true, + trapFocus = true, + initialFocusRef, + className, + children, +}: OverlayProps) => { + const containerRef = useRef(null) + useBodyScrollLock(open) + const focusableCacheRef = useOverlayFocus(open, initialFocusRef, containerRef) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (closeOnEscape && event.key === 'Escape') { + event.stopPropagation() + onClose() + return + } + if (!trapFocus || event.key !== 'Tab') return + const focusable = focusableCacheRef.current + if (focusable.length === 0) return + trapFocusWithinOverlay(event, focusable) + }, + [closeOnEscape, onClose, trapFocus], + ) + + const handleBackdropClick = useCallback( + (event: React.MouseEvent) => { + if (closeOnBackdrop && event.target === event.currentTarget) onClose() + }, + [closeOnBackdrop, onClose], + ) + + if (!open) return null + + return ( +
+
+ {children} +
+
+ ) +} diff --git a/src/components/studio/ui/shared-primitives.tsx b/src/components/studio/ui/shared-primitives.tsx index c85427f7..bc21a8d3 100644 --- a/src/components/studio/ui/shared-primitives.tsx +++ b/src/components/studio/ui/shared-primitives.tsx @@ -1,6 +1,7 @@ -import { forwardRef, useEffect, useRef, useCallback } from 'react' +import { forwardRef } from 'react' import type { LucideIcon } from 'lucide-react' import { cn } from '@/lib/utils' +export { Overlay } from './overlay' // --------------------------------------------------------------------------- // Button @@ -314,196 +315,3 @@ export function SwitchToggle({ ) } - -// --------------------------------------------------------------------------- -// Overlay (Dialog) — ADR 014 -// --------------------------------------------------------------------------- - -type OverlayVariant = 'center' | 'sheet-bottom' | 'sheet-left' | 'fullscreen' - -interface OverlayProps { - open: boolean - onClose: () => void - /** Accessible label for the dialog */ - 'aria-label'?: string - /** ID of the element that labels the dialog */ - 'aria-labelledby'?: string - /** Visual variant */ - variant?: OverlayVariant - /** Whether clicking the backdrop closes the dialog */ - closeOnBackdrop?: boolean - /** Whether pressing Escape closes the dialog */ - closeOnEscape?: boolean - /** Whether to trap focus within the dialog */ - trapFocus?: boolean - /** Ref to the element that should receive initial focus */ - initialFocusRef?: React.RefObject - className?: string - children: React.ReactNode -} - -const VARIANT_CONTAINER: Record = { - center: - 'm-auto max-h-[calc(100dvh-2rem)] w-[min(100%-2rem,32rem)] overflow-y-auto rounded-xl', - 'sheet-bottom': - 'mx-auto mt-auto max-h-[calc(100dvh-2rem)] w-full max-w-lg overflow-y-auto rounded-t-xl', - 'sheet-left': - 'h-dvh w-[min(86vw,340px)] overflow-y-auto', - fullscreen: - 'h-full w-full overflow-y-auto', -} - -function getVariantClasses(variant: OverlayVariant): string { - switch (variant) { - case 'center': - return VARIANT_CONTAINER.center - case 'sheet-bottom': - return VARIANT_CONTAINER['sheet-bottom'] - case 'sheet-left': - return VARIANT_CONTAINER['sheet-left'] - case 'fullscreen': - return VARIANT_CONTAINER.fullscreen - default: - return VARIANT_CONTAINER.center - } -} - -// Module-level scroll lock ref-count for nested overlays -let scrollLockCount = 0 -let savedScrollbarWidth = 0 - -/** Accessible modal overlay with focus trap, scroll lock, and configurable layout variant. */ -export function Overlay({ - open, - onClose, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - variant = 'center', - closeOnBackdrop = true, - closeOnEscape = true, - trapFocus = true, - initialFocusRef, - className, - children, -}: OverlayProps) { - const containerRef = useRef(null) - const previousFocusRef = useRef(null) - const focusableCacheRef = useRef([]) - - // Body scroll lock with ref-counting for nested overlays - useEffect(() => { - if (open) { - if (scrollLockCount === 0) { - savedScrollbarWidth = window.innerWidth - document.documentElement.clientWidth - document.body.style.overflow = 'hidden' - document.body.style.paddingRight = `${savedScrollbarWidth}px` - } - scrollLockCount++ - return () => { - scrollLockCount-- - if (scrollLockCount === 0) { - document.body.style.overflow = '' - document.body.style.paddingRight = '' - } - } - } - }, [open]) - - // Save and restore focus; cache focusable elements on open - useEffect(() => { - if (open) { - previousFocusRef.current = document.activeElement as HTMLElement - const container = containerRef.current - if (container) { - focusableCacheRef.current = Array.from( - container.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ), - ) - const target = initialFocusRef?.current ?? focusableCacheRef.current[0] - ;(target ?? container).focus() - } - } else { - focusableCacheRef.current = [] - if (previousFocusRef.current) { - previousFocusRef.current.focus() - previousFocusRef.current = null - } - } - }, [open, initialFocusRef]) - - // Escape key handler - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (closeOnEscape && e.key === 'Escape') { - e.stopPropagation() - onClose() - } - - // Focus trap - if (trapFocus && e.key === 'Tab') { - const focusable = focusableCacheRef.current - if (focusable.length === 0) return - - const first = focusable[0] - const last = focusable[focusable.length - 1] - - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault() - last.focus() - } - } else { - if (document.activeElement === last) { - e.preventDefault() - first.focus() - } - } - } - }, - [closeOnEscape, onClose, trapFocus], - ) - - // Backdrop click handler - const handleBackdropClick = useCallback( - (e: React.MouseEvent) => { - if (closeOnBackdrop && e.target === e.currentTarget) { - onClose() - } - }, - [closeOnBackdrop, onClose], - ) - - if (!open) return null - - return ( -
-
- {children} -
-
- ) -} diff --git a/src/components/studio/views/encrypt-export-dialog.tsx b/src/components/studio/views/encrypt-export-dialog.tsx index 3f80833d..a3ee48a2 100644 --- a/src/components/studio/views/encrypt-export-dialog.tsx +++ b/src/components/studio/views/encrypt-export-dialog.tsx @@ -113,7 +113,7 @@ export const EncryptExportDialog = memo(function EncryptExportDialog({ Cancel + ) : ( + + · + + )} + + ) +} + +interface MatrixRowProps { + rowIndex: number + rowLabel: string + filtered: FilteredParameter[] + worsening: number | null + matrixHighlight: string | null + onSelectCell: (improving: number, worsening: number) => void +} + +/** Matrix row with a sticky worsening-parameter label and selectable cells. */ +const MatrixRow = ({ + rowIndex, + rowLabel, + filtered, + worsening, + matrixHighlight, + onSelectCell, +}: MatrixRowProps) => ( + + + {rowIndex + 1} + {rowLabel.length > 12 ? `${rowLabel.slice(0, 12)}…` : rowLabel} + + {filtered.map(({ index: colIndex, label: colLabel }) => { + const key = `${rowIndex}-${colIndex}` + return ( + + ) + })} + +) + +interface MatrixTableProps { + filtered: FilteredParameter[] + improving: number | null + worsening: number | null + matrixHighlight: string | null + onSelectCell: (improving: number, worsening: number) => void +} + +/** Matrix table with an improving-parameter header and contradiction rows. */ +const MatrixTable = ({ + filtered, + improving, + worsening, + matrixHighlight, + onSelectCell, +}: MatrixTableProps) => ( + + + + + + {filtered.map(({ label, index }) => ( + + ))} + + + + {filtered.map(({ label: rowLabel, index: rowIndex }) => ( + + ))} + +
TRIZ Contradiction Matrix
+ ↓ Improving → Worsening + + {index + 1} +
+) + +export interface TrizMatrixViewProps { + matrixSearch: string + onMatrixSearchChange: (value: string) => void + improving: number | null + worsening: number | null + onSelectCell: (improving: number, worsening: number) => void +} + +/** Full contradiction matrix table with search filtering and selection highlighting. */ +export const TrizMatrixView = ({ + matrixSearch, + onMatrixSearchChange, + improving, + worsening, + onSelectCell, +}: TrizMatrixViewProps) => { + const reducedMotion = useReducedMotion() + const matrixHighlight = + improving !== null && worsening !== null ? `${improving}-${worsening}` : null + const filtered = useMemo(() => filterParams(matrixSearch), [matrixSearch]) + return ( + +
+
+ +

Contradiction Matrix

+ + {TRIZ_PARAMETERS.length} parameters × {TRIZ_PRINCIPLES.length} principles + +
+

+ Click any cell to see the recommended inventive principles. Highlighted rows/columns show your current selection. +

+ { onMatrixSearchChange(event.target.value) }} + placeholder="Filter parameters…" + aria-label="Filter TRIZ contradiction matrix parameters" + className="mb-3" + /> +
+ +
+
+
+ ) +} diff --git a/src/components/studio/views/triz-results-view.tsx b/src/components/studio/views/triz-results-view.tsx new file mode 100644 index 00000000..151a5f8a --- /dev/null +++ b/src/components/studio/views/triz-results-view.tsx @@ -0,0 +1,203 @@ +'use client' + +import { ArrowRight, RotateCcw, Copy, Check, Sparkles } from 'lucide-react' +import { motion } from 'framer-motion' +import { TRIZ_PARAMETERS, type TrizPrinciple } from '@/lib/studio/triz-data' +import { useReducedMotion } from '@/lib/studio/use-reduced-motion' +import { ContradictionChip } from './triz-helpers' + +interface PrincipleCardProps { + principle: TrizPrinciple + index: number + copied: number | null + onCopy: (text: string, id: number) => void + reducedMotion: boolean +} + +/** Card displaying a single suggested inventive principle with a copy action. */ +const PrincipleCard = ({ + principle, + index, + copied, + onCopy, + reducedMotion, +}: PrincipleCardProps) => { + const { name, id, description, examples } = principle + return ( + +
+
+ #{id} +
+ +
+

+ {name} +

+

{description}

+ {examples.length > 0 && ( +
+

Examples

+
    + {examples.slice(0, 3).map((example) => ( +
  • {example}
  • + ))} +
+
+ )} +
+ ) +} + +interface ResultsSummaryProps { + improving: number + worsening: number + hasSuggestions: boolean +} + +const ResultsSummary = ({ improving, worsening, hasSuggestions }: ResultsSummaryProps) => ( +
+
+ Your contradiction +
+
+ + + +
+

+ You want to improve {TRIZ_PARAMETERS.at(improving)?.toLowerCase() ?? ''}, + but doing so worsens {TRIZ_PARAMETERS.at(worsening)?.toLowerCase() ?? ''}. + {hasSuggestions + ? ' TRIZ suggests these inventive principles:' + : ' No principles found for this pair in the matrix. Try a different combination.'} +

+
+) + +interface ResultsPrinciplesProps { + suggestedPrinciples: TrizPrinciple[] + copied: number | null + onCopy: (text: string, id: number) => void + reducedMotion: boolean +} + +const ResultsPrinciples = ({ + suggestedPrinciples, + copied, + onCopy, + reducedMotion, +}: ResultsPrinciplesProps) => { + if (suggestedPrinciples.length === 0) return null + return ( + <> +
+ +

+ Suggested inventive principles +

+ + {suggestedPrinciples.length} + +
+
+ {suggestedPrinciples.map((principle, index) => ( + + ))} +
+ + ) +} + +interface ResultsActionsProps { + onReset: () => void + onChangeParams: () => void +} + +const ResultsActions = ({ onReset, onChangeParams }: ResultsActionsProps) => ( +
+ + +
+) + +export interface TrizResultsViewProps { + improving: number | null + worsening: number | null + suggestedPrinciples: TrizPrinciple[] + copied: number | null + onCopy: (text: string, id: number) => void + onReset: () => void + onChangeParams: () => void +} + +/** Results view showing suggested inventive principles for the selected contradiction. */ +export const TrizResultsView = ({ + improving, + worsening, + suggestedPrinciples, + copied, + onCopy, + onReset, + onChangeParams, +}: TrizResultsViewProps) => { + const reducedMotion = useReducedMotion() + if (improving === null || worsening === null) return null + return ( + + 0} + /> + + + + ) +} diff --git a/src/components/studio/views/triz-subviews.tsx b/src/components/studio/views/triz-subviews.tsx new file mode 100644 index 00000000..a2ab9e68 --- /dev/null +++ b/src/components/studio/views/triz-subviews.tsx @@ -0,0 +1,166 @@ +'use client' + +import { + Grid3X3, + RotateCcw, + Sparkles, + List, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { motion } from 'framer-motion' +import { ToggleButtonGroup } from '../ui/shared-primitives' +import { useReducedMotion } from '@/lib/studio/use-reduced-motion' +import { ParamPicker } from './triz-helpers' +import { filterParams } from './triz-view-utils' + +export { filterParams } from './triz-view-utils' + +type TrizViewId = 'pick' | 'results' | 'matrix' + +interface TrizHeaderProps { + view: TrizViewId + onViewChange: (view: TrizViewId) => void + resultsCount: number + hasSelection: boolean + onReset: () => void +} + +/** Header with view toggle buttons, branding, and reset action for the TRIZ matrix. */ +export const TrizHeader = ({ + view, + onViewChange, + resultsCount, + hasSelection, + onReset, +}: TrizHeaderProps) => { + const reducedMotion = useReducedMotion() + const toggleClasses = (active: boolean) => + cn( + 'rounded px-2.5 py-1 text-[12px] font-medium transition-colors focus-ring min-h-[44px]', + active ? 'bg-primary text-primary-foreground shadow-sm' : 'text-ink-mute hover:text-ink', + ) + return ( + +
+
+ +
+
+
+

TRIZ Contradiction Matrix

+ + Lab + +
+

+ Pick an improving parameter and a worsening parameter — the matrix suggests inventive principles. +

+
+
+ +
+ + + + {resultsCount > 0 && ( + + )} + + + {hasSelection && ( + + )} +
+
+ ) +} + +interface TrizPickViewProps { + improving: number | null + worsening: number | null + search: string + filteredParams: ReturnType + onImprovingChange: (index: number) => void + onWorseningChange: (index: number) => void + onSearchChange: (value: string) => void +} + +/** Pick view containing the improving and worsening parameter selectors. */ +export const TrizPickView = ({ + improving, + worsening, + search, + filteredParams, + onImprovingChange, + onWorseningChange, + onSearchChange, +}: TrizPickViewProps) => { + const reducedMotion = useReducedMotion() + return ( + + + + + ) +} diff --git a/src/components/studio/views/triz-view-utils.ts b/src/components/studio/views/triz-view-utils.ts new file mode 100644 index 00000000..16e54352 --- /dev/null +++ b/src/components/studio/views/triz-view-utils.ts @@ -0,0 +1,7 @@ +import { TRIZ_PARAMETERS } from '@/lib/studio/triz-data' + +/** Shared filter: maps TRIZ parameters to selectable items and filters by query. */ +export const filterParams = (query: string) => + TRIZ_PARAMETERS.map((label, index) => ({ label, index })).filter( + (parameter) => !query || parameter.label.toLowerCase().includes(query.toLowerCase()), + ) diff --git a/src/components/studio/views/triz-view.tsx b/src/components/studio/views/triz-view.tsx index db1c3a37..bdee758d 100644 --- a/src/components/studio/views/triz-view.tsx +++ b/src/components/studio/views/triz-view.tsx @@ -1,59 +1,31 @@ 'use client' -import { - Grid3X3, - ArrowRight, - RotateCcw, - Copy, - Check, - Sparkles, - Eye, - List, -} from 'lucide-react' import { useState, useMemo, useRef, useEffect } from 'react' -import { cn } from '@/lib/utils' -import { motion } from 'framer-motion' import { toast } from 'sonner' -import { TRIZ_PARAMETERS, TRIZ_PRINCIPLES, TRIZ_MATRIX, lookupPrinciples } from '@/lib/studio/triz-data' -import { TextInput, ToggleButtonGroup } from '../ui/shared-primitives' -import { useReducedMotion } from '@/lib/studio/use-reduced-motion' -import { ParamPicker, ContradictionChip } from './triz-helpers' +import { lookupPrinciples } from '@/lib/studio/triz-data' +import { + filterParams, + TrizHeader, + TrizPickView, +} from './triz-subviews' +import { TrizMatrixView } from './triz-matrix-view' +import { TrizResultsView } from './triz-results-view' /** TRIZ contradiction matrix view for picking parameters and viewing suggested inventive principles. */ -export function TrizView() { +export const TrizView = () => { const [improving, setImproving] = useState(null) const [worsening, setWorsening] = useState(null) const [search, setSearch] = useState('') const [copied, setCopied] = useState(null) - const reducedMotion = useReducedMotion() const [view, setView] = useState<'pick' | 'results' | 'matrix'>('pick') const [matrixSearch, setMatrixSearch] = useState('') - const suggestedPrinciples = useMemo(() => { - if (improving === null || worsening === null) return [] - return lookupPrinciples(improving, worsening) - }, [improving, worsening]) - - const filteredParams = useMemo( - () => - TRIZ_PARAMETERS - .map((p, i) => ({ label: p, index: i })) - .filter((p) => !search || p.label.toLowerCase().includes(search.toLowerCase())), - [search], + const suggestedPrinciples = useMemo( + () => improving === null || worsening === null ? [] : lookupPrinciples(improving, worsening), + [improving, worsening], ) - const matrixHighlight = useMemo(() => { - if (improving === null || worsening === null) return null - return `${improving}-${worsening}` - }, [improving, worsening]) - - const filteredMatrixParams = useMemo( - () => - TRIZ_PARAMETERS - .map((p, i) => ({ label: p, index: i })) - .filter((p) => !matrixSearch || p.label.toLowerCase().includes(matrixSearch.toLowerCase())), - [matrixSearch], - ) + const filteredParams = useMemo(() => filterParams(search), [search]) const handleReset = () => { setImproving(null) @@ -62,7 +34,13 @@ export function TrizView() { } const handleCopy = (text: string, id: number) => { - navigator.clipboard?.writeText(text) + try { + // Clipboard can be unavailable in insecure contexts; rejection is non-fatal. + navigator.clipboard.writeText(text).catch(() => undefined) + } catch (error) { + // Clipboard API missing entirely (e.g. non-secure context) — non-fatal. + console.error('Clipboard API unavailable:', error) + } setCopied(id) toast.success('Principle copied to clipboard') clearTimeout(copiedTimerRef.current) @@ -75,340 +53,62 @@ export function TrizView() { return () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current) } }, []) - return ( -
- {/* Header */} - -
-
- -
-
-
-

TRIZ Contradiction Matrix

- - Lab - -
-

- Pick an improving parameter and a worsening parameter — the matrix suggests inventive principles. -

-
-
- - {/* View toggle + Stepper */} -
- - - - {suggestedPrinciples.length > 0 && ( - - )} - - - {(improving !== null || worsening !== null) && ( - - )} -
-
- - {/* Step 1: Pick contradiction */} - {view === 'pick' && ( - - { - setImproving(i) - if (worsening !== null) setView('results') - }} - search={search} - setSearch={setSearch} - filtered={filteredParams} - disabled={[]} - /> - - { - setWorsening(i) - if (improving !== null) setView('results') - }} - search={search} - setSearch={setSearch} - filtered={filteredParams} - disabled={improving !== null ? [improving] : []} - /> - - )} - - {/* Matrix view */} - {view === 'matrix' && ( - -
-
- -

Contradiction Matrix

- - {Object.keys(TRIZ_PARAMETERS).length} parameters × {TRIZ_PRINCIPLES.length} principles - -
-

- Click any cell to see the recommended inventive principles. Highlighted rows/columns show your current selection. -

- { setMatrixSearch(e.target.value) }} - placeholder="Filter parameters…" - aria-label="Filter TRIZ contradiction matrix parameters" - className="mb-3" - /> -
- - - - - - {filteredMatrixParams.map(({ index }) => ( - - ))} - - - - {filteredMatrixParams.map(({ label: rowLabel, index: rowIndex }) => ( - - - {filteredMatrixParams.map(({ index: colIndex }) => { - const key = `${rowIndex}-${colIndex}` - const hasEntry = key in TRIZ_MATRIX - const isHighlighted = matrixHighlight === key - return ( - - ) - })} - - ))} - -
TRIZ Contradiction Matrix
- ↓ Improving → Worsening - - {index + 1} -
- {rowIndex + 1} - {rowLabel.length > 12 ? `${rowLabel.slice(0, 12)}…` : rowLabel} - { - if (hasEntry) { - setImproving(colIndex) - setWorsening(rowIndex) - setView('results') - } - }} - onKeyDown={(e) => { - if (hasEntry && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault() - setImproving(colIndex) - setWorsening(rowIndex) - setView('results') - } - }} - > - {hasEntry ? '●' : '·'} -
-
-
-
- )} + const handleSelectCell = (improvingIndex: number, worseningIndex: number) => { + setImproving(improvingIndex) + setWorsening(worseningIndex) + setView('results') + } - {/* Step 2: Results */} - {view === 'results' && improving !== null && worsening !== null && ( - - {/* Contradiction summary */} -
-
- Your contradiction -
-
- - - -
-

- You want to improve {TRIZ_PARAMETERS[improving]?.toLowerCase() ?? ''}, - but doing so worsens {TRIZ_PARAMETERS[worsening]?.toLowerCase() ?? ''}. - {suggestedPrinciples.length > 0 - ? ' TRIZ suggests these inventive principles:' - : ' No principles found for this pair in the matrix. Try a different combination.'} -

-
+ const handleImprovingChange = (index: number) => { + setImproving(index) + if (worsening !== null) setView('results') + } - {/* Suggested principles */} - {suggestedPrinciples.length > 0 && ( - <> -
- -

- Suggested inventive principles -

- - {suggestedPrinciples.length} - -
+ const handleWorseningChange = (index: number) => { + setWorsening(index) + if (improving !== null) setView('results') + } -
- {suggestedPrinciples.map((p, i) => ( - -
-
- #{p.id} -
- -
-

- {p.name} -

-

{p.description}

- {p.examples.length > 0 && ( -
-

Examples

-
    - {p.examples.slice(0, 3).map((ex) => ( -
  • {ex}
  • - ))} -
-
- )} -
- ))} -
- - )} + const viewContent = view === 'pick' ? ( + + ) : view === 'matrix' ? ( + + ) : ( + { setView('pick') }} + /> + ) - {/* Try another */} -
- - -
-
- )} + return ( +
+ + {viewContent}
) } diff --git a/src/components/studio/views/type-selector.tsx b/src/components/studio/views/type-selector.tsx index c88b9ca0..1e595a27 100644 --- a/src/components/studio/views/type-selector.tsx +++ b/src/components/studio/views/type-selector.tsx @@ -30,7 +30,7 @@ function renderTypeIcon(t: EntityType, className?: string) { } /** Dropdown selector for choosing an entity type with keyboard navigation. */ -export function TypeSelector({ +export const TypeSelector = ({ type, showMenu, onToggleMenu, @@ -40,7 +40,7 @@ export function TypeSelector({ showMenu: boolean onToggleMenu: () => void onSelect: (t: EntityType) => void -}) { +}) => { const menuRef = useRef(null) const meta = getTypeMeta(type) @@ -86,7 +86,9 @@ export function TypeSelector({ const nextIdx = e.key === 'ArrowDown' ? (currentIdx + 1) % options.length : (currentIdx - 1 + options.length) % options.length - options[nextIdx]?.focus() + // nextIdx is always within [0, options.length) via the modulo above, + // and the non-empty guard runs earlier — .item() is safe to call directly. + options.item(nextIdx).focus() } }} > diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 18ca8fe7..c4e7c5f2 100755 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -50,14 +50,12 @@ const FormField = < const useFormField = () => { const fieldContext = React.useContext(FormFieldContext) const itemContext = React.useContext(FormItemContext) + // fieldContext is never null (context has a default value) and is dereferenced + // above, so the shadcn guard is dead code — omitted deliberately. const { getFieldState } = useFormContext() const formState = useFormState({ name: fieldContext.name }) const fieldState = getFieldState(fieldContext.name, formState) - if (!fieldContext) { - throw new Error("useFormField should be used within ") - } - const { id } = itemContext return { diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts index 39708e48..74497f12 100755 --- a/src/hooks/use-mobile.ts +++ b/src/hooks/use-mobile.ts @@ -3,7 +3,7 @@ import * as React from "react" const MOBILE_BREAKPOINT = 768 /** Hook that returns true when the viewport width is below the mobile breakpoint. */ -export function useIsMobile() { +export const useIsMobile = () => { const [isMobile, setIsMobile] = React.useState(undefined) React.useEffect(() => { @@ -13,7 +13,9 @@ export function useIsMobile() { } mql.addEventListener("change", onChange) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) + return () => { + mql.removeEventListener("change", onChange) + } }, []) return !!isMobile diff --git a/src/lib/ai/providers.ts b/src/lib/ai/providers.ts index 032813b5..5c47ad69 100644 --- a/src/lib/ai/providers.ts +++ b/src/lib/ai/providers.ts @@ -294,6 +294,7 @@ class OllamaAdapter implements ProviderAdapter { } // URL is validated to localhost-only by validateOllamaUrl above + // nosemgrep: rules.lgpl.javascript.ssrf.rule-node-ssrf const res = await fetch(`${validatedUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -343,6 +344,7 @@ export const fetchOllamaModels = async ( signal?: AbortSignal, ): Promise => { const validatedUrl = validateOllamaUrl(baseUrl) + // nosemgrep: rules.lgpl.javascript.ssrf.rule-node-ssrf const res = await fetch(`${validatedUrl}/api/tags`, { signal }) if (!res.ok) throw new Error(`Ollama tags error ${res.status}`) const data = OllamaTagsSchema.parse(await res.json()) diff --git a/src/lib/ai/research.ts b/src/lib/ai/research.ts index 24a1d674..8c8015eb 100644 --- a/src/lib/ai/research.ts +++ b/src/lib/ai/research.ts @@ -211,6 +211,7 @@ export const fetchUrlContent = async ( } const encodedUrl = encodeURIComponent(url) + // nosemgrep: rules.lgpl.javascript.ssrf.rule-node-ssrf — scheme + isPrivateIP guarded above const res = await fetch(`${JINA_READER_ENDPOINT}${encodedUrl}`, { headers: { Accept: 'text/markdown',