Skip to content

feat: Sprint 8 — AI triggers, 130 tests, production polish - #5

Merged
lemone112 merged 8 commits into
mainfrom
feat/sprint-8-tests-polish-ai-triggers
Mar 13, 2026
Merged

feat: Sprint 8 — AI triggers, 130 tests, production polish#5
lemone112 merged 8 commits into
mainfrom
feat/sprint-8-tests-polish-ai-triggers

Conversation

@lemone112

@lemone112 lemone112 commented Mar 13, 2026

Copy link
Copy Markdown
Owner

Summary

  • AI Feature UI Triggers: 4 new QuickAction buttons (Brand Audit, Copy/Tone, Persona Sim, A11y Spec) calling backend AI endpoints with loading states
  • Test Suite: 130 tests across plugin lint modules (73) and backend services (31), all passing via Vitest
  • Production Polish: ErrorBoundary, defensive null guards in 3 card components, loading spinner overlay, pulse animation on analysis phases

Changes

  • 20 files changed, +5,202 / -153 lines
  • New: 5 test files, 1 ErrorBoundary component
  • Modified: App.tsx, QuickActions, ChatContainer, MessageList, ScoreCard, IssuesList, AiReviewCard, main.tsx, .gitignore

Verification

  • npx tsc --noEmit — all 3 projects clean
  • npm run bundle — plugin builds (281.8kb)
  • npm run build — UI builds (342.76kb)
  • npx vitest run — 99 plugin tests pass
  • cd backend && npx vitest run — 31 backend tests pass
  • Snyk SAST — 0 issues across all codebases

Test plan

  • Verify Brand Audit / Copy-Tone / Persona Sim / A11y Spec buttons appear in QuickActions "More..." section
  • Verify loading spinner shows during async AI calls
  • Verify ErrorBoundary catches render crashes gracefully
  • Verify ScoreCard/IssuesList/AiReviewCard handle null data without crashing

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Четыре AI‑анализа (бренд‑аудит, тон копирайта, исследование персоны, спецификация доступности), извлечение текста из скриншотов, новые быстрые действия и индикатор загрузки.
    • Много новых UI‑компонентов и улучшенная тема/анимации (аватар, кнопки, код‑блоки, лоадеры, Markdown, чат‑контейнер и др.).
  • Bug Fixes

    • Глобальный ErrorBoundary и ранние guards, предотвращающие рендер/ошибки при отсутствии данных.
  • Tests

    • Большой набор новых юнит- и маршрутовых тестов.
  • Chores

    • Переименование в «Bezier», обновлена production‑контейнерная конфигурация и манифесты.

AI Feature UI Triggers:
- Add Brand Audit, Copy/Tone, Persona Sim, A11y Spec buttons to QuickActions
- Wire 4 action handlers in App.tsx calling backend API endpoints
- Persist lastScreenshot ref so AI features work after initial analysis
- Add isActionLoading state with spinner feedback during async calls

Test Suite (130 tests, all passing):
- Lint modules: checkLayoutSizing, checkConstraints, checkTypography,
  checkComponentProps (55 tests with mock Figma nodes)
- Core design-lint: runDesignLint, DEFAULT_LINT_SETTINGS (18 tests)
- Backend: confidence-filter (15), three-layer prompts (8), route
  validation (8) — no Anthropic API calls
- Install vitest in both plugin and backend, add test scripts
- Remove *.test.ts from .gitignore

Production Polish:
- ErrorBoundary component wrapping entire app (catches render crashes)
- Defensive null guards in ScoreCard, IssuesList, AiReviewCard
- Loading spinner overlay in QuickActions during async operations
- Pulse animation on active analysis phase indicator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 13, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Добавлены новые AI‑анализы и их UI‑потоки (brand, copy, persona, a11y) с извлечением текста из скриншотов; усилена серверная валидация и rate‑limiting; введён sanitiser для prompt’ов; много новых UI‑компонентов, ErrorBoundary и крупный набор тестов.

Changes

Cohort / File(s) Summary
Конфигурации & метаданные
package.json, manifest.json, src/manifest.json, ui/package.json, ui/index.html, .gitignore, ui/components.json
Переименование проекта в Bezier, обновлены UI/backend package.json, добавлен ui компоненты конфиг, удалены тест‑паттерны из .gitignore, изменён title.
Docker & backend packaging
backend/Dockerfile, backend/package.json
Многоступенчатая сборка (deps/build/production), non‑root runtime, HEALTHCHECK, добавлен vitest и тест‑скрипт; backend пакет переименован.
Тесты (backend & core)
backend/src/__tests__/*, src/lint/__tests__/*
Добавлены обширные тесты для confidence‑filter, маршрутов, three‑layer prompt, runDesignLint и отдельных lint‑модулей.
Backend: маршруты, валидация и middleware
backend/src/index.ts, backend/src/routes/..., backend/src/middleware/rate-limit.ts, backend/src/middleware/auth.ts, backend/src/services/analyzer.ts
Добавлен aiRateLimit и применён к AI‑эндпоинтам; усилена валидация (flow, page, dark‑mode), timing‑safe сравнение токенов, улучшено логирование Refero; рефактор rate limiter.
Backend: prompts & sanitization
backend/src/utils/sanitize.ts, backend/src/prompts/*, backend/src/prompts/persona-research.ts
Новый модуль sanitise с функциями sanitizeText, sanitizeTextArray, wrapUserContent; применён во множестве prompt‑генераторов; PersonaPrompt расширен systemPrompt.
Frontend: события и текст из скриншотов
src/ui/message-handler.ts, ui/src/lib/messages.ts
После token‑summary добавлена попытка извлечь textContent (try/catch); screenshot-result теперь может содержать textContent.
Frontend: API и защитные меры
ui/src/lib/api.ts
Добавлен fetchWithTimeout, проверка максимума для скриншотов, замена fetch на таймаут‑обёртки; валидация размеров скриншота перед вызовами.
Frontend: основной UI и AI‑фичи
ui/src/App.tsx, ui/src/main.tsx
Добавлены действия brand‑audit, copy‑tone, persona‑sim, a11y‑spec; введено isActionLoading, lastScreenshot с textContent; App обёрнут в ErrorBoundary.
Frontend: компоненты и UX primitives
ui/src/components/..., ui/src/components/ui/*, ui/src/components/shared/*
Много новых/изменённых UI‑компонентов (Button, Avatar, Loader, CodeBlock, Markdown, Message, PromptInput, ChatContainerRoot/Content/Anchor, ErrorBoundary, QuickActions и др.), guards в карточках/списках, новый prop isActionLoading.
UI сборка, стили и конфигурация
ui/tailwind.config.js, ui/tsconfig.json, ui/vite.config.ts, ui/src/styles/globals.css
Расширены тема/анимации Tailwind, добавлены алиасы TS/Vite (@src), перемещены root‑токены в @layer base, добавлено prefers‑reduced‑motion.
Мелкие правки и нейминги
src/code.ts, src/fix/naming-fixer.ts, backend/src/index.ts, src/api/providers/openai.ts
Замены текстовых меток/логов/комментариев: «FigmaLint» → «Bezier».

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as "UI (App)"
    participant Chat as "ChatContainer"
    participant Backend as "Backend API"
    participant AI as "AI Service"

    User->>UI: инициирует действие (Brand/Copy/Persona/A11y)
    UI->>UI: валидация (backendAvailable, screenshot, textContent, lint)
    UI->>Chat: set isActionLoading = true
    Chat->>Chat: показать индикатор загрузки
    UI->>Backend: POST /api/... (payload + textContent + lint)
    Backend->>AI: формирование prompt (sanitized)
    AI-->>Backend: возвращает результаты
    Backend-->>UI: отдает JSON-результат
    UI->>UI: обновляет pending/lastScreenshot и результаты
    UI->>Chat: set isActionLoading = false
    Chat->>Chat: скрыть индикатор
    UI->>User: отобразить результаты
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Тихо шуршат тесты в утренних лучах,
Boundary охраняет каждый шаг.
Текст и prompt чисты — морковка в руках,
Bezier бежит — и мир за ним в чередах.
Пусть код растёт, как кроличий лес в качах.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly summarizes the main changes: adding AI triggers (4 new QuickAction buttons), comprehensive test coverage (130 tests), and production polish improvements. Specific, concise, and directly related to the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/sprint-8-tests-polish-ai-triggers
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review

Copy link
Copy Markdown
ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Review Summary by Qodo

Sprint 8 — AI triggers, 130 tests, production polish

✨ Enhancement 🧪 Tests

Grey Divider

Walkthroughs

Description
• Add 4 new AI feature QuickAction buttons (Brand Audit, Copy/Tone, Persona Sim, A11y Spec) with
  backend API integration
• Implement 130 passing tests across plugin lint modules (73 tests) and backend services (31 tests)
  using Vitest
• Add ErrorBoundary component wrapping entire app to gracefully catch and handle render crashes
• Add defensive null guards in 3 card components (ScoreCard, IssuesList, AiReviewCard) to prevent
  crashes on missing data
• Add loading spinner overlay and pulse animation on active analysis phase indicator for better UX
  feedback
Diagram
flowchart LR
  A["App.tsx<br/>AI Action Handlers"] -->|calls| B["API Functions<br/>analyzeBrand, copyTone, etc."]
  B -->|sends| C["Backend Routes<br/>analyze endpoint"]
  C -->|returns| D["Chat Messages<br/>brand-consistency, copy-tone, etc."]
  E["QuickActions<br/>4 new buttons"] -->|triggers| A
  E -->|shows| F["Loading Spinner<br/>isActionLoading state"]
  G["ErrorBoundary<br/>wraps App"] -->|catches| H["Render Errors<br/>graceful fallback"]
  I["Test Files<br/>130 tests"] -->|validate| J["Lint Modules<br/>Layout, Constraints, Typography, Props"]
  I -->|validate| K["Backend Services<br/>Confidence, Routes, Prompts"]
Loading

Grey Divider

File Changes

1. src/lint/__tests__/lint-modules.test.ts 🧪 Tests +940/-0

Comprehensive unit tests for 4 lint modules

src/lint/tests/lint-modules.test.ts


2. src/lint/__tests__/design-lint.test.ts 🧪 Tests +439/-0

Core design-lint orchestrator tests with mocks

src/lint/tests/design-lint.test.ts


3. backend/src/__tests__/confidence-filter.test.ts 🧪 Tests +179/-0

Backend confidence scoring and filtering tests

backend/src/tests/confidence-filter.test.ts


View more (14)
4. backend/src/__tests__/routes.test.ts 🧪 Tests +142/-0

Route validation and error handling tests

backend/src/tests/routes.test.ts


5. backend/src/__tests__/three-layer.test.ts 🧪 Tests +102/-0

Three-layer prompt generation tests

backend/src/tests/three-layer.test.ts


6. ui/src/App.tsx ✨ Enhancement +145/-2

Add 4 AI action handlers and loading state management

ui/src/App.tsx


7. ui/src/components/shared/QuickActions.tsx ✨ Enhancement +39/-3

Add 4 new AI feature buttons with loading indicator

ui/src/components/shared/QuickActions.tsx


8. ui/src/components/shared/ErrorBoundary.tsx Error handling +76/-0

New ErrorBoundary component for crash handling

ui/src/components/shared/ErrorBoundary.tsx


9. ui/src/components/chat/ChatContainer.tsx ✨ Enhancement +3/-0

Pass isActionLoading prop to QuickActions

ui/src/components/chat/ChatContainer.tsx


10. ui/src/components/chat/MessageList.tsx ✨ Enhancement +1/-1

Add pulse animation to active analysis phase

ui/src/components/chat/MessageList.tsx


11. ui/src/components/messages/ScoreCard.tsx 🐞 Bug fix +2/-0

Add null guard for missing data

ui/src/components/messages/ScoreCard.tsx


12. ui/src/components/messages/IssuesList.tsx 🐞 Bug fix +3/-0

Add null guard for empty errors array

ui/src/components/messages/IssuesList.tsx


13. ui/src/components/messages/AiReviewCard.tsx 🐞 Bug fix +2/-0

Add null guard for missing review data

ui/src/components/messages/AiReviewCard.tsx


14. ui/src/main.tsx ✨ Enhancement +4/-1

Wrap App with ErrorBoundary component

ui/src/main.tsx


15. package.json Dependencies +3/-2

Add vitest dependency and test script

package.json


16. backend/package.json Dependencies +4/-2

Add vitest dependency and test script

backend/package.json


17. dist/ui.html Additional files +28/-28

...

dist/ui.html


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Mar 13, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Brand audit always fails🐞 Bug ✓ Correctness
Description
App's brand-audit action sends brandGuide.personality: [], but the backend
/api/brand-consistency route rejects empty personality arrays, so this action will always fail
with HTTP 400.
Code

ui/src/App.tsx[R771-781]

+          analyzeBrandConsistency({
+            screenshot: brandSs.screenshot,
+            brandGuide: {
+              colors: {},
+              typography: {
+                heading: { family: '', weights: [] },
+                body: { family: '', weights: [] },
+              },
+              spacing: { base: 8, scale: [4, 8, 12, 16, 24, 32, 48, 64] },
+              personality: [],
+            },
Evidence
The UI hard-codes brandGuide.personality to an empty array, while the backend explicitly requires
it to be a non-empty array; therefore every request from this handler will be rejected before
analysis runs.

ui/src/App.tsx[759-784]
backend/src/routes/brand-consistency.ts[105-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The UI `brand-audit` action always sends `brandGuide.personality: []`, but the backend rejects empty personality arrays, causing a deterministic HTTP 400 on every click.
### Issue Context
Backend validation requires `brandGuide.personality` to be a non-empty string array.
### Fix Focus Areas
- ui/src/App.tsx[759-792]
- backend/src/routes/brand-consistency.ts[105-114]
### Implementation notes
- Prefer UI-side fix: require/configure a brand guide (including personality) before enabling the Brand Audit button; or inject a sensible default personality array if you want a “works out of the box” path.
- If empty personality should be valid, adjust backend validation and ensure prompt/service behavior handles missing personality appropriately.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Copy/Tone always fails🐞 Bug ✓ Correctness
Description
App's copy-tone action posts screens[].textContent as an empty array, but the backend
/api/copy-tone route requires each textContent array to be non-empty, so this action will always
fail with HTTP 400.
Code

ui/src/App.tsx[R806-808]

+          analyzeCopyTone({
+            screens: [{ name: componentName || lastScreenshot.current.nodeName || 'Current Screen', textContent: [] }],
+            sessionId: chat.sessionId || undefined,
Evidence
The UI hard-codes textContent: [] and there is no later population step in this handler, while the
backend enforces textContent.length > 0; thus requests are always rejected during route
validation.

ui/src/App.tsx[795-809]
backend/src/routes/copy-tone.ts[40-58]
src/utils/figma-helpers.ts[165-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The UI `copy-tone` action always sends `textContent: []`, but the backend rejects empty `textContent` arrays, causing a deterministic HTTP 400.
### Issue Context
The plugin already has a text extraction helper (`extractTextContent`) that can produce the required string[] payload.
### Fix Focus Areas
- ui/src/App.tsx[795-818]
- src/ui/message-handler.ts[1259-1289]
- src/utils/figma-helpers.ts[165-185]
- backend/src/routes/copy-tone.ts[40-58]
### Implementation notes
- Add a UI state/ref to store extracted text content for the last analyzed selection.
- Extend the plugin-&amp;gt;UI `screenshot-result` payload to include `textContent: string[]` (using `extractTextContent(node)`), or add a dedicated message/action to fetch it on demand.
- Use that extracted text array when building `screens: [{ name, textContent }]`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread ui/src/App.tsx Outdated
Comment thread ui/src/App.tsx
Docker Build:
- Add python3/make/g++ to Alpine builder stages for better-sqlite3
  native compilation (node-gyp needs Python)
- 3-stage Dockerfile: builder → deps → production (no build tools in
  final image)

Brand Audit (review comment #1):
- Backend brand-consistency route now defaults personality to
  ['professional', 'clear', 'consistent'] when empty/omitted instead
  of rejecting with 400

Copy/Tone (review comment #2):
- Plugin screenshot-result event now includes textContent[] extracted
  via extractTextContent(node)
- App.tsx stores textContent in lastScreenshot ref
- Copy-tone action uses real extracted text instead of empty array
- Shows "No text content found" message if selection has no text layers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (5)
backend/Dockerfile (1)

31-32: Добавьте --start-period в HEALTHCHECK.

Это снизит риск ложного unhealthy статуса во время холодного старта контейнера.

Предлагаемая правка
-HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
   CMD wget -qO- http://localhost:3000/api/health || exit 1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/Dockerfile` around lines 31 - 32, HEALTHCHECK currently has no
--start-period which can cause false unhealthy states during cold starts; update
the HEALTHCHECK instruction (the line starting with HEALTHCHECK --interval=30s
--timeout=5s --retries=3 CMD wget -qO- http://localhost:3000/api/health || exit
1) to include a suitable --start-period (for example --start-period=30s or
--start-period=60s) so the container has a grace period before health probes
begin; keep the rest of the flags (--interval, --timeout, --retries) unchanged.
src/lint/__tests__/lint-modules.test.ts (1)

849-940: Сведите повторяющиеся проверки структуры LintIssue в общий helper.

Сейчас один и тот же набор assert-ов продублирован в нескольких тестах; это удорожает поддержку при изменении контракта.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lint/__tests__/lint-modules.test.ts` around lines 849 - 940, Extract the
repeated assertions about LintIssue into a single helper (e.g.,
expectLintIssueShape or assertLintIssues) and call it from each test; move the
block of expect(...).toHaveProperty(...) and common type checks into that helper
and accept either a single issue or an array (issues) so tests using
checkLayoutSizing, checkConstraints, checkTypography, and checkComponentProps
simply invoke the helper with result.issues (or map over issues) instead of
repeating the assertions inline; keep the helper near the tests so it’s easy to
reuse and update the LintIssue contract in one place.
backend/src/__tests__/routes.test.ts (1)

31-33: Сделайте проверку версии менее хрупкой.

Жёсткое сравнение с 1.0.0 будет падать при любом релизном bump, даже если endpoint корректен.

Предлагаемая правка
-    expect(body.version).toBe('1.0.0');
+    expect(typeof body.version).toBe('string');
+    expect(body.version as string).toMatch(/^\d+\.\d+\.\d+(?:[-+].+)?$/);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/__tests__/routes.test.ts` around lines 31 - 33, Текест ожидания
версии слишком жёсткий — вместо expect(body.version).toBe('1.0.0') измените
проверку на менее хрупкую: либо импортируйте package.json и сравнивайте с
текущей версии пакета (например expect(body.version).toBe(pkg.version)), либо
используйте semver-валидатор (semver.valid(body.version) не должен быть null)
или простую регулярку (expect(body.version).toMatch(/^\d+\.\d+\.\d+(-.+)?$/));
оставьте проверку статуса как есть (body.status).
src/lint/__tests__/design-lint.test.ts (1)

348-362: Название теста сейчас шире фактической проверки.

В текущем кейсе реально проверяется только GROUP; SLICE и COMPONENT_SET не покрыты. Лучше либо сузить название, либо добавить параметризованный тест по всем трём типам.

Предложение (параметризованный кейс)
-  it('skips GROUP, SLICE, and COMPONENT_SET nodes in lintNode', () => {
-    // GROUP nodes should be traversed for children but not lint-checked themselves
-    const group: any = {
-      id: nextNodeId(),
-      name: 'Group',
-      type: 'GROUP',
-      visible: true,
-      locked: false,
-      children: [],
-    };
-
-    const result = runDesignLint([group]);
-    expect(result.summary.totalNodes).toBe(1);
-    expect(result.errors).toHaveLength(0);
-  });
+  it.each(['GROUP', 'SLICE', 'COMPONENT_SET'] as const)(
+    'skips %s nodes in lintNode',
+    (nodeType) => {
+      const node: any = {
+        id: nextNodeId(),
+        name: nodeType,
+        type: nodeType,
+        visible: true,
+        locked: false,
+        children: [],
+      };
+
+      const result = runDesignLint([node]);
+      expect(result.summary.totalNodes).toBe(1);
+      expect(result.errors).toHaveLength(0);
+    },
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lint/__tests__/design-lint.test.ts` around lines 348 - 362, Rename the
test or expand it to actually cover all three node types: either change the test
title to only mention GROUP (e.g., "skips GROUP nodes in lintNode") or make it
parameterized to iterate over ['GROUP','SLICE','COMPONENT_SET'] and assert the
same expectations for each; locate the test that calls runDesignLint (the
current "skips GROUP, SLICE, and COMPONENT_SET nodes in lintNode" test) and
update it to create and run a node for each type (or adjust the title) while
keeping assertions against runDesignLint's result.summary.totalNodes and
result.errors unchanged.
ui/src/components/shared/ErrorBoundary.tsx (1)

27-29: Реализация корректна, рассмотрите защиту от бесконечных retry-циклов.

Функция handleRetry корректно сбрасывает состояние ошибки. Однако, если ошибка воспроизводится при каждом рендере, пользователь может попасть в цикл "ошибка → retry → ошибка". Для улучшения UX можно добавить счётчик попыток и после N неудачных retry показывать предложение перезагрузить плагин.

♻️ Опциональное улучшение с ограничением retry
 interface ErrorBoundaryState {
   hasError: boolean;
   error: Error | null;
+  retryCount: number;
 }

 export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
+  static MAX_RETRIES = 3;
+
   constructor(props: ErrorBoundaryProps) {
     super(props);
-    this.state = { hasError: false, error: null };
+    this.state = { hasError: false, error: null, retryCount: 0 };
   }

   static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
-    return { hasError: true, error };
+    return { hasError: true, error };
   }

   handleRetry = (): void => {
-    this.setState({ hasError: false, error: null });
+    this.setState((prev) => ({
+      hasError: false,
+      error: null,
+      retryCount: prev.retryCount + 1,
+    }));
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/shared/ErrorBoundary.tsx` around lines 27 - 29, handleRetry
currently resets hasError and error which can cause infinite retry loops if the
error persists; add a retry counter to the ErrorBoundary state (e.g.,
retryCount) and a MAX_RETRIES constant, increment retryCount inside handleRetry
and only reset hasError/error if retryCount < MAX_RETRIES, otherwise set a flag
like showReload (or disable the retry button) to prompt the user to
reload/uninstall the plugin; update render logic to show retry button until max
attempts and then show the reload suggestion using the new
retryCount/MAX_RETRIES and showReload flags.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/src/routes/brand-consistency.ts`:
- Around line 105-108: Normalize bg.personality instead of only checking for
emptiness: when handling brandGuide.personality (bg.personality) first ensure
it's an array, then create a filtered array that keeps only non-empty strings
(trimmed), assign that filtered array back to bg.personality, and only if the
filtered array is empty set the default ['professional','clear','consistent'];
update the logic around the existing Array.isArray(bg.personality) check to
perform the filtering and defaulting.

In `@src/lint/__tests__/lint-modules.test.ts`:
- Around line 13-15: The test currently mutates globalThis.figma without
restoring it; capture the original value (const _origFigma = (globalThis as
any).figma) before assigning the mock, set (globalThis as any).figma = { mixed:
MIXED_SENTINEL }, and ensure you restore the original in an afterEach/afterAll
hook ((globalThis as any).figma = _origFigma) so other tests are unaffected;
update the setup around the code that assigns globalThis.figma in
lint-modules.test.ts accordingly.

In `@ui/src/App.tsx`:
- Around line 771-781: The brand-audit currently sends a placeholder brand guide
to analyzeBrandConsistency (called with brandSs.screenshot), which leaves
colors, typography, and personality empty; update the action to read the saved
teamConfig/brand settings (e.g., pull from teamConfig or the component/state
that stores brand guide) and pass those real values into analyzeBrandConsistency
instead of the hardcoded empty object, and if no valid brand guide exists then
disable/guard the action (prevent calling analyzeBrandConsistency or show/throw
an error) until the guide is configured.
- Line 30: The lastScreenshot ref type is missing the incoming ssData metadata
(hasAutoLayout and childCount), causing generateA11ySpec to receive defaults;
update the useRef type for lastScreenshot to include hasAutoLayout: boolean and
childCount: number (e.g., extend the existing shape) and ensure any assignments
to lastScreenshot.current (where ssData is stored) preserve these fields so
generateA11ySpec receives the real values.
- Around line 764-766: The checks only verify that lastScreenshot exists but not
that it matches the current UI state; update all AI-request branches (where
lastScreenshot.current is used, e.g., the blocks around the shown diff and the
other ranges noted) to validate screenshot freshness by comparing a stable
identifier on the screenshot (e.g., screenshot.scanId, screenshot.version, or
timestamp stored on lastScreenshot.current) against the current scan/scanVersion
produced by the latest fix-all/rescan action (or a
currentScanId/currentScanVersion value); if they differ, reject the cached
screenshot (call chat.addMessage with the "Run an analysis first…" message or
trigger a fresh capture) so Brand/Copy/Persona/A11y analyses always run against
a screenshot that matches the current state. Ensure this same freshness check is
added wherever lastScreenshot.current is referenced for AI requests.

In `@ui/src/components/shared/QuickActions.tsx`:
- Line 25: The container currently uses the CSS class pointer-events-none when
isLoading is true which only blocks mouse clicks but leaves buttons
keyboard-focusable; in the QuickActions component replace that approach by
removing pointer-events-none and applying semantic disabling: either wrap the
action buttons render (see the button elements around the QuickActions render,
referenced near the existing button block at line ~96) in a <fieldset
disabled={isLoading}> or add the disabled prop to each button based on
isLoading, and keep the visual opacity logic (isLoading ? 'opacity-50' : '') so
the UI remains visually dimmed while controls are truly disabled for both mouse
and keyboard.

---

Nitpick comments:
In `@backend/Dockerfile`:
- Around line 31-32: HEALTHCHECK currently has no --start-period which can cause
false unhealthy states during cold starts; update the HEALTHCHECK instruction
(the line starting with HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD
wget -qO- http://localhost:3000/api/health || exit 1) to include a suitable
--start-period (for example --start-period=30s or --start-period=60s) so the
container has a grace period before health probes begin; keep the rest of the
flags (--interval, --timeout, --retries) unchanged.

In `@backend/src/__tests__/routes.test.ts`:
- Around line 31-33: Текест ожидания версии слишком жёсткий — вместо
expect(body.version).toBe('1.0.0') измените проверку на менее хрупкую: либо
импортируйте package.json и сравнивайте с текущей версии пакета (например
expect(body.version).toBe(pkg.version)), либо используйте semver-валидатор
(semver.valid(body.version) не должен быть null) или простую регулярку
(expect(body.version).toMatch(/^\d+\.\d+\.\d+(-.+)?$/)); оставьте проверку
статуса как есть (body.status).

In `@src/lint/__tests__/design-lint.test.ts`:
- Around line 348-362: Rename the test or expand it to actually cover all three
node types: either change the test title to only mention GROUP (e.g., "skips
GROUP nodes in lintNode") or make it parameterized to iterate over
['GROUP','SLICE','COMPONENT_SET'] and assert the same expectations for each;
locate the test that calls runDesignLint (the current "skips GROUP, SLICE, and
COMPONENT_SET nodes in lintNode" test) and update it to create and run a node
for each type (or adjust the title) while keeping assertions against
runDesignLint's result.summary.totalNodes and result.errors unchanged.

In `@src/lint/__tests__/lint-modules.test.ts`:
- Around line 849-940: Extract the repeated assertions about LintIssue into a
single helper (e.g., expectLintIssueShape or assertLintIssues) and call it from
each test; move the block of expect(...).toHaveProperty(...) and common type
checks into that helper and accept either a single issue or an array (issues) so
tests using checkLayoutSizing, checkConstraints, checkTypography, and
checkComponentProps simply invoke the helper with result.issues (or map over
issues) instead of repeating the assertions inline; keep the helper near the
tests so it’s easy to reuse and update the LintIssue contract in one place.

In `@ui/src/components/shared/ErrorBoundary.tsx`:
- Around line 27-29: handleRetry currently resets hasError and error which can
cause infinite retry loops if the error persists; add a retry counter to the
ErrorBoundary state (e.g., retryCount) and a MAX_RETRIES constant, increment
retryCount inside handleRetry and only reset hasError/error if retryCount <
MAX_RETRIES, otherwise set a flag like showReload (or disable the retry button)
to prompt the user to reload/uninstall the plugin; update render logic to show
retry button until max attempts and then show the reload suggestion using the
new retryCount/MAX_RETRIES and showReload flags.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 98875681-328c-44ef-a76c-dc297db2cc0b

📥 Commits

Reviewing files that changed from the base of the PR and between 8b0a10e and 620dee0.

⛔ Files ignored due to path filters (4)
  • backend/package-lock.json is excluded by !**/package-lock.json
  • dist/code.js is excluded by !**/dist/**
  • dist/ui.html is excluded by !**/dist/**
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (21)
  • .gitignore
  • backend/Dockerfile
  • backend/package.json
  • backend/src/__tests__/confidence-filter.test.ts
  • backend/src/__tests__/routes.test.ts
  • backend/src/__tests__/three-layer.test.ts
  • backend/src/routes/brand-consistency.ts
  • package.json
  • src/lint/__tests__/design-lint.test.ts
  • src/lint/__tests__/lint-modules.test.ts
  • src/ui/message-handler.ts
  • ui/src/App.tsx
  • ui/src/components/chat/ChatContainer.tsx
  • ui/src/components/chat/MessageList.tsx
  • ui/src/components/messages/AiReviewCard.tsx
  • ui/src/components/messages/IssuesList.tsx
  • ui/src/components/messages/ScoreCard.tsx
  • ui/src/components/shared/ErrorBoundary.tsx
  • ui/src/components/shared/QuickActions.tsx
  • ui/src/lib/messages.ts
  • ui/src/main.tsx

Comment thread backend/src/routes/brand-consistency.ts Outdated
Comment thread src/lint/__tests__/lint-modules.test.ts
Comment thread ui/src/App.tsx Outdated
Comment thread ui/src/App.tsx
Comment thread ui/src/App.tsx Outdated
Comment on lines +771 to +781
analyzeBrandConsistency({
screenshot: brandSs.screenshot,
brandGuide: {
colors: {},
typography: {
heading: { family: '', weights: [] },
body: { family: '', weights: [] },
},
spacing: { base: 8, scale: [4, 8, 12, 16, 24, 32, 48, 64] },
personality: [],
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

brand-audit сейчас уходит с заглушкой вместо реального brand guide.

Здесь colors пустой, у typography пустые family/weights, а personality всегда []. В итоге бэкенду буквально не с чем сравнивать цвета и типографику, так что аудит легко станет слишком общим или просто неверным. Подставьте сохранённый teamConfig/настройки бренда или блокируйте action, пока guide не настроен.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/App.tsx` around lines 771 - 781, The brand-audit currently sends a
placeholder brand guide to analyzeBrandConsistency (called with
brandSs.screenshot), which leaves colors, typography, and personality empty;
update the action to read the saved teamConfig/brand settings (e.g., pull from
teamConfig or the component/state that stores brand guide) and pass those real
values into analyzeBrandConsistency instead of the hardcoded empty object, and
if no valid brand guide exists then disable/guard the action (prevent calling
analyzeBrandConsistency or show/throw an error) until the guide is configured.

Comment thread ui/src/components/shared/QuickActions.tsx Outdated
Actionable:
- Normalize brandGuide.personality (filter non-string/empty values)
- Restore globalThis.figma in afterAll (lint-modules.test.ts)
- Add hasAutoLayout/childCount to lastScreenshot ref type + pass real values to generateA11ySpec
- Add selectionStale freshness check to all AI actions (brand/copy/persona/a11y)
- Use teamConfig brand guide instead of empty placeholder in brand-audit
- Replace pointer-events-none with <fieldset disabled> in QuickActions (keyboard a11y)

Nitpicks:
- Add --start-period=20s to Dockerfile HEALTHCHECK
- Use semver regex instead of hardcoded '1.0.0' in routes.test.ts
- Parametrize GROUP/SLICE/COMPONENT_SET skip test (design-lint.test.ts)
- Extract expectLintIssueShape helper (lint-modules.test.ts)
- Add retry limit (MAX_RETRIES=3) to ErrorBoundary

Verified: tsc clean (root + backend), 132 tests pass, builds OK, Snyk SAST 0 issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
ui/src/App.tsx (1)

768-770: ⚠️ Potential issue | 🟠 Major

Проверка свежести скриншота всё ещё недостаточна для AI-веток.

На Line 768 / 810 / 844 / 874 вы полагаетесь на selectionStale, но он не покрывает рескан того же nodeId. В этом случае можно отправить устаревший lastScreenshot вместе с новыми lint-данными.

💡 Вариант исправления через версионирование анализа
+ const analysisEpochRef = useRef(0);

- const lastScreenshot = useRef<{ screenshot: string; nodeId: string; nodeName: string; width: number; height: number; hasAutoLayout?: boolean; childCount?: number; textContent?: string[] } | null>(null);
+ const lastScreenshot = useRef<{
+   screenshot: string;
+   nodeId: string;
+   nodeName: string;
+   width: number;
+   height: number;
+   hasAutoLayout?: boolean;
+   childCount?: number;
+   textContent?: string[];
+   analysisEpoch: number;
+ } | null>(null);

  const handleAnalyze = useCallback(() => {
+   analysisEpochRef.current += 1;
    ...
  }, [chat, post]);

  case 'screenshot-result': {
    const ssData = event.data as { ... };
-   lastScreenshot.current = ssData;
+   lastScreenshot.current = { ...ssData, analysisEpoch: analysisEpochRef.current };
    ...
  }

+ const hasFreshScreenshot = () =>
+   !!lastScreenshot.current &&
+   lastScreenshot.current.analysisEpoch === analysisEpochRef.current;

  case 'brand-audit': {
-   if (!lastScreenshot.current) {
+   if (!hasFreshScreenshot()) {
      chat.addMessage({ kind: 'ai-text', content: 'Run an analysis first to capture a screenshot.' });
      break;
    }

Also applies to: 810-812, 844-846, 874-876

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/App.tsx` around lines 768 - 770, The selectionStale check is
insufficient because it doesn't detect when the same nodeId was re-scanned (so
lastScreenshot may be stale relative to new lintResults); update the logic
around selectionStale (used before chat.addMessage in the AI branches) to also
compare lastScreenshot.nodeId === current.nodeId and a screenshot
version/timestamp (e.g., lastScreenshot.updatedAt or screenshotVersion) against
the lintResults.analysisAt (or include a lintResults.version), and if they
mismatch treat as stale (prompt a re-scan) or refresh lastScreenshot before
proceeding; ensure references to selectionStale, lastScreenshot, nodeId,
lintResults, and the chat.addMessage branch are updated accordingly.
🧹 Nitpick comments (5)
ui/src/components/shared/ErrorBoundary.tsx (1)

69-73: Рекомендация: скрыть детали ошибки за раскрывающимся блоком.

Отображение error.message напрямую полезно для отладки, но может раскрывать детали реализации конечным пользователям. Для плагина Figma это допустимо, однако можно улучшить UX, сделав детали скрываемыми по умолчанию.

♻️ Опциональное улучшение
             {this.state.error && (
-              <p className="text-11 text-fg-tertiary font-mono break-all">
-                {this.state.error.message}
-              </p>
+              <details className="text-left">
+                <summary className="text-11 text-fg-tertiary cursor-pointer hover:text-fg-secondary">
+                  Показать детали
+                </summary>
+                <p className="text-11 text-fg-tertiary font-mono break-all mt-1">
+                  {this.state.error.message}
+                </p>
+              </details>
             )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/shared/ErrorBoundary.tsx` around lines 69 - 73, Replace the
direct rendering of this.state.error.message with a collapsible/details pattern
so error details are hidden by default: render a short, non-sensitive error
label (e.g., "An error occurred") visible to users and put the full
this.state.error.message inside a <details> with a <summary> (or a small toggle
button) that expands to reveal the message; update the ErrorBoundary render
branch that currently checks this.state.error to use that pattern and ensure the
summary/toggle is keyboard-accessible and uses aria attributes for
accessibility.
backend/Dockerfile (2)

16-16: Используйте современный флаг --omit=dev вместо устаревшего --production.

На строке 16 флаг --production помечен как deprecated в npm v10+ официальной документацией. Рекомендуется использовать npm ci --omit=dev для обеспечения совместимости с будущими версиями npm.

Предлагаемый diff
-RUN npm ci --production
+RUN npm ci --omit=dev
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/Dockerfile` at line 16, Replace the deprecated npm flag used in the
Dockerfile RUN command: change the RUN instruction that currently reads "npm ci
--production" to use the modern flag "npm ci --omit=dev" to ensure compatibility
with npm v10+, and update any other occurrences of "npm ... --production" in the
Dockerfile to the new form.

19-23: Добавьте ENV NODE_ENV=production в финальный runtime-образ.

Переменная окружения NODE_ENV не задана в Dockerfile и docker-compose.yml. Код приложения использует проверку process.env.NODE_ENV === 'development' в middleware аутентификации (backend/src/middleware/auth.ts), что подразумевает, что production должен быть установлен явно. Без этого зависимости могут работать в нежелательном режиме.

Предлагаемый diff
 FROM node:22.15-alpine3.21
 WORKDIR /app
+ENV NODE_ENV=production
 COPY --from=deps /app/node_modules/ ./node_modules/
 COPY --from=builder /app/dist/ ./dist/
 COPY package*.json ./
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/Dockerfile` around lines 19 - 23, Add an explicit production NODE_ENV
to the final runtime image by setting ENV NODE_ENV=production in the
Dockerfile's final stage (the image starting FROM node:22.15-alpine3.21 where
WORKDIR /app and COPY --from=builder /app/dist/ ./dist/ are used); also ensure
docker-compose.yml defines NODE_ENV: "production" for the service so runtime
checks like process.env.NODE_ENV === 'development' in
backend/src/middleware/auth.ts behave correctly in production.
src/lint/__tests__/design-lint.test.ts (1)

348-364: Рассмотрите добавление теста для узлов с вложенными children в GROUP.

Параметризованный тест проверяет, что GROUP, SLICE и COMPONENT_SET не генерируют ошибки, но узел создаётся с пустым children: []. Было бы полезно добавить тест, проверяющий, что дети GROUP-узла всё равно обходятся (если это ожидаемое поведение design-lint).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lint/__tests__/design-lint.test.ts` around lines 348 - 364, Add a new
parameterized case (or a separate test) in design-lint.test.ts that creates a
GROUP node with non-empty children (use nextNodeId() to generate ids and the
same node shape) and passes it to runDesignLint to assert children are
traversed: call runDesignLint([groupNode]) and assert summary.totalNodes
accounts for the group plus its children and that errors behave as expected
(e.g., errors length is 0 if GROUP children should be skipped). Locate the
existing test using runDesignLint and nextNodeId and extend or duplicate it to
cover nested children under the GROUP node.
ui/src/components/shared/QuickActions.tsx (1)

17-23: Сделайте индикатор загрузки озвучиваемым для screen reader.

На Line 19 индикатор рендерится как обычный div, поэтому динамическое состояние может не объявляться ассистивными технологиями. Лучше пометить его как live status.

💡 Предлагаемое улучшение
-      {isLoading && (
-        <div className="flex items-center gap-2 px-3 py-1 text-11 text-fg-secondary">
+      {isLoading && (
+        <div
+          role="status"
+          aria-live="polite"
+          className="flex items-center gap-2 px-3 py-1 text-11 text-fg-secondary"
+        >
           <div className="w-3 h-3 border-[1.5px] border-bg-brand border-t-transparent rounded-full animate-spin shrink-0" />
           <span>Running...</span>
         </div>
       )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/shared/QuickActions.tsx` around lines 17 - 23, The loading
indicator in the QuickActions component (rendered when isLoading is true) is a
plain div and isn’t announced to screen readers; update the container that wraps
the spinner and "Running..." text to be an accessible live region (e.g., add
role="status" and aria-live="polite" and aria-atomic="true") so assistive tech
receives the dynamic state change, and mark the decorative spinner as
non‑informative (e.g., aria-hidden) while keeping the visible "Running..." text
announceable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@ui/src/App.tsx`:
- Around line 768-770: The selectionStale check is insufficient because it
doesn't detect when the same nodeId was re-scanned (so lastScreenshot may be
stale relative to new lintResults); update the logic around selectionStale (used
before chat.addMessage in the AI branches) to also compare lastScreenshot.nodeId
=== current.nodeId and a screenshot version/timestamp (e.g.,
lastScreenshot.updatedAt or screenshotVersion) against the
lintResults.analysisAt (or include a lintResults.version), and if they mismatch
treat as stale (prompt a re-scan) or refresh lastScreenshot before proceeding;
ensure references to selectionStale, lastScreenshot, nodeId, lintResults, and
the chat.addMessage branch are updated accordingly.

---

Nitpick comments:
In `@backend/Dockerfile`:
- Line 16: Replace the deprecated npm flag used in the Dockerfile RUN command:
change the RUN instruction that currently reads "npm ci --production" to use the
modern flag "npm ci --omit=dev" to ensure compatibility with npm v10+, and
update any other occurrences of "npm ... --production" in the Dockerfile to the
new form.
- Around line 19-23: Add an explicit production NODE_ENV to the final runtime
image by setting ENV NODE_ENV=production in the Dockerfile's final stage (the
image starting FROM node:22.15-alpine3.21 where WORKDIR /app and COPY
--from=builder /app/dist/ ./dist/ are used); also ensure docker-compose.yml
defines NODE_ENV: "production" for the service so runtime checks like
process.env.NODE_ENV === 'development' in backend/src/middleware/auth.ts behave
correctly in production.

In `@src/lint/__tests__/design-lint.test.ts`:
- Around line 348-364: Add a new parameterized case (or a separate test) in
design-lint.test.ts that creates a GROUP node with non-empty children (use
nextNodeId() to generate ids and the same node shape) and passes it to
runDesignLint to assert children are traversed: call runDesignLint([groupNode])
and assert summary.totalNodes accounts for the group plus its children and that
errors behave as expected (e.g., errors length is 0 if GROUP children should be
skipped). Locate the existing test using runDesignLint and nextNodeId and extend
or duplicate it to cover nested children under the GROUP node.

In `@ui/src/components/shared/ErrorBoundary.tsx`:
- Around line 69-73: Replace the direct rendering of this.state.error.message
with a collapsible/details pattern so error details are hidden by default:
render a short, non-sensitive error label (e.g., "An error occurred") visible to
users and put the full this.state.error.message inside a <details> with a
<summary> (or a small toggle button) that expands to reveal the message; update
the ErrorBoundary render branch that currently checks this.state.error to use
that pattern and ensure the summary/toggle is keyboard-accessible and uses aria
attributes for accessibility.

In `@ui/src/components/shared/QuickActions.tsx`:
- Around line 17-23: The loading indicator in the QuickActions component
(rendered when isLoading is true) is a plain div and isn’t announced to screen
readers; update the container that wraps the spinner and "Running..." text to be
an accessible live region (e.g., add role="status" and aria-live="polite" and
aria-atomic="true") so assistive tech receives the dynamic state change, and
mark the decorative spinner as non‑informative (e.g., aria-hidden) while keeping
the visible "Running..." text announceable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9c19382d-8878-459b-8edd-d1dab6f8d355

📥 Commits

Reviewing files that changed from the base of the PR and between 620dee0 and 2447498.

⛔ Files ignored due to path filters (1)
  • dist/ui.html is excluded by !**/dist/**
📒 Files selected for processing (8)
  • backend/Dockerfile
  • backend/src/__tests__/routes.test.ts
  • backend/src/routes/brand-consistency.ts
  • src/lint/__tests__/design-lint.test.ts
  • src/lint/__tests__/lint-modules.test.ts
  • ui/src/App.tsx
  • ui/src/components/shared/ErrorBoundary.tsx
  • ui/src/components/shared/QuickActions.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • backend/src/tests/routes.test.ts
  • backend/src/routes/brand-consistency.ts
  • src/lint/tests/lint-modules.test.ts

lemone112 and others added 2 commits March 13, 2026 21:17
…, rate limiting

HIGH:
- Add 90s timeout (fetchWithTimeout) to all 8 API calls in api.ts; UI no longer hangs forever
- Add prompt injection defense: sanitize.ts strips tags + enforces length limits on all user text
  interpolated into AI prompts (brand, copy-tone, persona, cognitive, chat-followup)

MEDIUM:
- flow.ts: validate frame structure (id/name/width/height), edge structure, max 50 frames
- page-sweep.ts: validate frame structure (id/name/screenshot), max 50 frames
- dark-mode.ts: validate modeData substructure (collection, modes, variableDiffs, missingValues)
- session.ts: wrap all JSON.parse in try/catch to handle corrupted stored data
- Per-route AI rate limit (15 req/min) separate from general limit (60 req/min)
- Remove 4 `as any` casts in tryBackendAnalysis — proper type for screenshot param

LOW:
- Persona-sim taskDescription now uses componentName context instead of generic string
- Add 10MB screenshot size validation before API calls
- Log Refero background errors instead of silent swallow
- Constant-time auth token comparison (crypto.timingSafeEqual)

Verified: tsc clean (root + backend), 132 tests pass, builds OK, Snyk SAST 0 issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rebrand:
- "FigmaLint" → "Bezier" in manifest, package.json (root/ui/backend),
  code.ts, message-handler (user-visible strings only),
  chat-followup prompt, MessageList empty state, index.html title
- Storage keys (setSharedPluginData 'figmalint') preserved for backward compat

shadcn/ui + prompt-kit foundation:
- Path aliases (@/) in tsconfig.json + vite.config.ts
- shadcn components.json, cn() utility in lib/utils.ts
- CSS variable bridge: shadcn tokens → Figma runtime tokens (auto dark mode)
- Tailwind config extended with shadcn semantic colors + border-radius

prompt-kit components installed (12 files):
- ChatContainer, Message, Markdown, CodeBlock, Loader, TextShimmer
- PromptInput, Textarea, ScrollButton, Button, Avatar, Tooltip

Verified: tsc clean (all 3 projects), 132 tests pass, builds OK, Snyk 0 issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/manifest.json (1)

1-13: ⚠️ Potential issue | 🟡 Minor

Обнаружен дублирующий manifest.json с отличающейся конфигурацией.

В репозитории присутствуют два файла манифеста: manifest.json (корень) и src/manifest.json. Они имеют существенные различия:

Поле manifest.json src/manifest.json
id 1521241390290871981 ai-design-copilot
allowedDomains 5 доменов только Anthropic

Рекомендуется удалить дубликат или объединить конфигурации, чтобы избежать путаницы при сборке/деплое.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/manifest.json` around lines 1 - 13, There are two differing manifest
files (root manifest.json and src/manifest.json); remove the duplicate or merge
them into a single canonical manifest by reconciling key fields (ensure the
desired id value is kept, and combine networkAccess.allowedDomains so the final
manifest includes all required domains including Anthropic), and make sure api,
main, ui, editorType, permissions and documentAccess are consistent across the
project; update whatever build/deploy references expect the manifest so only the
canonical manifest is used.
backend/src/prompts/brand-consistency.ts (1)

27-46: ⚠️ Potential issue | 🟠 Major

Санитизация тут всё ещё неполная.

В prompt по-прежнему попадают сырые color-keys, typography.heading.family, typography.body.family и rules[].id. Раз уж этот builder закрывает prompt-injection через санитизацию, эти поля тоже нужно прогонять через sanitizeText, иначе обход остаётся через другие строки brand guide.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/prompts/brand-consistency.ts` around lines 27 - 46, The prompt
builder still emits unsanitized values (color keys, typography.heading.family,
typography.body.family, and rules[].id); update serialization to run those
through sanitizeText (or sanitizeTextArray where appropriate) before
interpolating. Specifically: when building the colors block, pass the color
key/name and c.hex/c.usage through sanitizeText; when serializing typography use
sanitizeText(brandGuide.typography.heading.family) and
sanitizeText(brandGuide.typography.body.family) and sanitizeText on each weight
string if weights can be arbitrary; and when emitting custom rules ensure r.id
and r.severity (and r.description is already sanitized) are sanitized via
sanitizeText before formatting in brandGuide.rules.map. Ensure you reuse
existing sanitizeText/sanitizeTextArray helpers and preserve the same output
structure.
backend/src/middleware/rate-limit.ts (1)

23-25: ⚠️ Potential issue | 🟠 Major

Установите trusted proxy для валидации IP-адресов forwarding-заголовков.

Код использует x-forwarded-for и x-real-ip без проверки, что они приходят от доверенного прокси. Attacker может подменить эти заголовки и обойти ограничения, используя разные "IP-адреса" для каждого запроса. Это особенно критично для aiRateLimit() с низким лимитом и дорогими запросами к AI.

Кроме того, parseInt() на строках 54-55 и 64-65 не валидирует результат. Если переменная окружения содержит невалидное значение, parseInt вернёт NaN, и сравнение entry.count > NaN всегда будет false, отключив лимитер.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/middleware/rate-limit.ts` around lines 23 - 25, The IP extraction
trusts unverified forwarding headers and must only accept
x-forwarded-for/x-real-ip when the request came through a trusted proxy: update
the ip calculation (the ip variable) to check the peer address (e.g.,
c.req.socket.remoteAddress or c.req.ip) against a configured TRUSTED_PROXIES
list and only parse the forwarding headers when the peer is in that allowlist,
otherwise use the remote peer address; also update aiRateLimit() usage to rely
on this validated ip. For the parseInt usages that read env vars (the parseInt
calls used to set limits/thresholds), validate the parsed value with
Number.isFinite/Number.isInteger (or Number.isNaN check) and provide a safe
default or throw a clear error when the env value is invalid so NaN cannot
silently disable the limiter.
🧹 Nitpick comments (10)
ui/vite.config.ts (1)

5-6: Избыточный импорт и несогласованное использование path.

Строка 5 уже импортирует resolve из 'path', а строка 6 добавляет импорт модуля path целиком. В строке 27 используется path.resolve(), тогда как в остальном файле (строки 13-15) используется resolve(). Это создаёт несогласованность.

♻️ Предлагаемое исправление: использовать существующий импорт
 import { defineConfig, type Plugin } from 'vite';
 import react from '@vitejs/plugin-react';
 import { viteSingleFile } from 'vite-plugin-singlefile';
 import { renameSync, existsSync } from 'fs';
 import { resolve } from 'path';
-import path from 'path';
   resolve: {
     alias: {
-      "@": path.resolve(__dirname, "./src"),
+      "@": resolve(__dirname, "./src"),
     },
   },

Also applies to: 27-27

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/vite.config.ts` around lines 5 - 6, В файле есть дублирующий импорт: и
named import resolve из 'path' и default import path; используйте существующий
named import — удалите "import path from 'path'" и заменить все вызовы
path.resolve() (в частности текущий вызов path.resolve) на resolve(), чтобы
сделать импорт и использование консистентными (смотрите символы resolve, path и
path.resolve в текущем диффе).
ui/src/components/ui/prompt-input.tsx (1)

29-37: React.createRef() в default context создаёт новый ref при каждом вызове.

При использовании usePromptInput() вне PromptInputContext.Provider, каждый рендер создаст новый ref-объект. Хотя компоненты должны использоваться внутри Provider, безопаснее использовать { current: null }.

♻️ Предлагаемое исправление
 const PromptInputContext = createContext<PromptInputContextType>({
   isLoading: false,
   value: "",
   setValue: () => {},
   maxHeight: 240,
   onSubmit: undefined,
   disabled: false,
-  textareaRef: React.createRef<HTMLTextAreaElement>(),
+  textareaRef: { current: null },
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/prompt-input.tsx` around lines 29 - 37, The default
context value for PromptInputContext uses React.createRef(), which creates a new
ref on each render; update the textareaRef default to a stable ref object like {
current: null } instead to avoid recreating refs when usePromptInput() is used
outside a Provider—change the textareaRef in the PromptInputContext default to {
current: null } and keep the rest of the PromptInputContextType fields unchanged
so components reading textareaRef (e.g., via usePromptInput) receive a stable
ref.
ui/src/App.tsx (1)

901-907: Небезопасное приведение типа (e as any).value.

Если LintError не содержит свойство value, лучше добавить его в тип или использовать type guard вместо as any.

♻️ Предлагаемое исправление
             lintResult: {
               summary: a11yLint.summary,
               errors: a11yLint.errors.map((e) => ({
                 nodeId: e.nodeId,
                 nodeName: e.nodeName,
                 errorType: e.errorType,
                 message: e.message,
-                value: (e as any).value ?? '',
+                value: 'value' in e ? String(e.value) : '',
               })),
             },

Или расширьте тип LintError в messages.ts, добавив опциональное свойство value?: string.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/App.tsx` around lines 901 - 907, The mapping uses an unsafe cast (e as
any). Fix by making LintError include an optional value?: string in the
messages.ts type definition or add a type guard that checks for 'value' on the
item before reading it; then update the mapping in App.tsx (the a11yLint.errors
map) to read e.value (or use the guarded value) without using "as any" so
TypeScript knows value is optional and safe to access.
ui/src/lib/api.ts (1)

38-44: Проверка размера base64 не учитывает кодирование.

screenshot.length — это длина base64-строки, а не размер исходных байтов. Base64 увеличивает размер примерно в 1.37 раза. Текущая проверка пропустит скриншот ~13.3MB (который закодирован как ~10MB base64) или отклонит валидный ~7.3MB файл.

♻️ Предлагаемое исправление
 const MAX_SCREENSHOT_BYTES = 10 * 1024 * 1024; // 10MB max screenshot
+// Base64 encoding increases size by ~4/3, so adjust threshold accordingly
+const MAX_SCREENSHOT_BASE64_LENGTH = Math.floor(MAX_SCREENSHOT_BYTES * 4 / 3);
 
 function validateScreenshot(screenshot: string): void {
-  if (screenshot.length > MAX_SCREENSHOT_BYTES) {
+  // Remove data URI prefix if present before checking length
+  const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, '');
+  if (base64Data.length > MAX_SCREENSHOT_BASE64_LENGTH) {
     throw new Error('Screenshot too large (max 10MB). Try selecting a smaller frame.');
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/lib/api.ts` around lines 38 - 44, The current validateScreenshot uses
screenshot.length (base64 characters) against MAX_SCREENSHOT_BYTES, which is
wrong because base64 expands size ~4/3; update validateScreenshot to first strip
any data URL prefix (data:*;base64,) then compute the actual byte size from the
base64 string by accounting for padding and conversion (bytes = (base64Length *
3) / 4 - padding) and compare that byte count to MAX_SCREENSHOT_BYTES; keep the
thrown Error message and the MAX_SCREENSHOT_BYTES constant unchanged and only
adjust the size calculation logic in validateScreenshot.
ui/src/components/ui/text-shimmer.tsx (1)

24-31: Дублирование длительности анимации.

Класс animate-[shimmer_4s_infinite_linear] задаёт 4s, но animationDuration в inline-стиле переопределяет это значение. Работает корректно (inline-стиль приоритетнее), но класс вводит в заблуждение.

♻️ Предлагаемое улучшение
       className={cn(
         "bg-size-[200%_auto] bg-clip-text font-medium text-transparent",
-        "animate-[shimmer_4s_infinite_linear]",
+        "animate-[shimmer_infinite_linear]",
         className
       )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/text-shimmer.tsx` around lines 24 - 31, The class string
contains a hardcoded duration token "animate-[shimmer_4s_infinite_linear]" which
conflicts with and misleads relative to the inline style animationDuration
(`duration` prop); remove the hardcoded "4s" from that class token (e.g.,
change/remove "animate-[shimmer_4s_infinite_linear]" so it no longer encodes a
duration) and keep the inline style animationDuration (`animationDuration:
\`${duration}s\``) as the single source of truth; update the class-building
expression that includes className so it no longer emits the "4s" variant.
backend/src/index.ts (1)

71-79: Можно упростить поддержку списка AI-роутов (DRY).

Сейчас 9 почти одинаковых вызовов app.use. Удобнее вынести пути в массив и пройтись циклом — меньше шанс пропустить новый AI-эндпоинт при следующих изменениях.

♻️ Вариант рефакторинга
-// Stricter rate limiting for AI-heavy routes
-app.use('/api/analyze', aiRateLimit());
-app.use('/api/analyze-flow', aiRateLimit());
-app.use('/api/analyze-page', aiRateLimit());
-app.use('/api/brand-consistency', aiRateLimit());
-app.use('/api/copy-tone', aiRateLimit());
-app.use('/api/persona-research', aiRateLimit());
-app.use('/api/generate-a11y-spec', aiRateLimit());
-app.use('/api/validate-dark-mode', aiRateLimit());
-app.use('/api/cognitive-walkthrough', aiRateLimit());
+// Stricter rate limiting for AI-heavy routes
+const aiHeavyRoutes = [
+  '/api/analyze',
+  '/api/analyze-flow',
+  '/api/analyze-page',
+  '/api/brand-consistency',
+  '/api/copy-tone',
+  '/api/persona-research',
+  '/api/generate-a11y-spec',
+  '/api/validate-dark-mode',
+  '/api/cognitive-walkthrough',
+];
+for (const route of aiHeavyRoutes) app.use(route, aiRateLimit());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/index.ts` around lines 71 - 79, Replace the nine repeated app.use
calls with a single iterable setup: gather the AI route paths (e.g.,
'/api/analyze', '/api/analyze-flow', '/api/analyze-page',
'/api/brand-consistency', '/api/copy-tone', '/api/persona-research',
'/api/generate-a11y-spec', '/api/validate-dark-mode',
'/api/cognitive-walkthrough') into an array and iterate over it to call
app.use(path, aiRateLimit()) for each; this keeps the behavior identical but
centralizes the list so future endpoints only need to be added to the array
(look for the repeated app.use and aiRateLimit references to locate the code).
ui/src/components/ui/message.tsx (2)

108-117: TooltipProvider создаётся для каждого действия.

Оборачивание каждого MessageAction в отдельный TooltipProvider может привести к избыточным перерендерам при использовании нескольких действий вместе. Рассмотрите вынос TooltipProvider на уровень MessageActions или выше.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/message.tsx` around lines 108 - 117, Компонент
TooltipProvider сейчас создаётся внутри MessageAction и будет рендериться для
каждого действия — вынесите провайдер на уровень выше (например в контейнер
MessageActions или родительский компонент), удалите обёртку TooltipProvider из
MessageAction (оставьте только Tooltip, TooltipTrigger и TooltipContent в
MessageAction), и убедитесь, что верхний компонент (MessageActions) оборачивает
всех дочерних MessageAction в единый TooltipProvider чтобы снизить лишние
перерендера.

47-53: Пересечение типов слишком широкое.

Тип MessageContentProps объединяет React.ComponentProps<typeof Markdown> и React.HTMLProps<HTMLDivElement>, но эти пропсы используются взаимоисключающе. Это позволяет передавать некорректные пропсы (например, onChange для Markdown).

♻️ Предлагаемое улучшение типов
-export type MessageContentProps = {
-  children: React.ReactNode
-  markdown?: boolean
-  className?: string
-} & React.ComponentProps<typeof Markdown> &
-  React.HTMLProps<HTMLDivElement>
+export type MessageContentProps =
+  | ({
+      children: string
+      markdown: true
+      className?: string
+    } & Omit<React.ComponentProps<typeof Markdown>, 'children'>)
+  | ({
+      children: React.ReactNode
+      markdown?: false
+      className?: string
+    } & React.HTMLProps<HTMLDivElement>)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/message.tsx` around lines 47 - 53, MessageContentProps
currently intersects React.ComponentProps<typeof Markdown> and
React.HTMLProps<HTMLDivElement> allowing invalid combinations; change it to a
discriminated union so props are mutually exclusive: one branch where markdown:
true (or markdown === true) extends React.ComponentProps<typeof Markdown> and
another branch where markdown is absent or false extends
React.HTMLProps<HTMLDivElement>, and remove the broad intersection; update
usages that rely on the union to narrow by the markdown flag (e.g.,
MessageContentProps with markdown key).
ui/src/components/ui/loader.tsx (1)

68-93: Сложные inline-стили можно упростить.

Вычисления marginLeft и transformOrigin через цепочки тернарных операторов снижают читаемость. Рассмотрите вынос в объект по аналогии с barSizes.

♻️ Пример рефакторинга
+  const originSizes = {
+    sm: { marginLeft: "-0.75px", originX: "0.75px", originY: "10px" },
+    md: { marginLeft: "-1px", originX: "1px", originY: "12px" },
+    lg: { marginLeft: "-1.25px", originX: "1.25px", originY: "14px" },
+  }
+
   // In style:
-  marginLeft: size === "sm" ? "-0.75px" : size === "lg" ? "-1.25px" : "-1px",
-  transformOrigin: `${size === "sm" ? "0.75px" : ...} ${...}`,
+  marginLeft: originSizes[size].marginLeft,
+  transformOrigin: `${originSizes[size].originX} ${originSizes[size].originY}`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/loader.tsx` around lines 68 - 93, The inline ternary
chains computing marginLeft and transformOrigin reduce readability; create a
small lookup object (e.g., offsets or sizeOffsets) keyed by size (same keys as
barSizes/sizeClasses) that contains marginLeft and transformOrigin values, then
replace the ternary expressions in the style block inside the map with
offsets[size].marginLeft and offsets[size].transformOrigin; keep barSizes and
sizeClasses as-is and ensure the new object is defined near them or at top of
the component for clarity.
ui/src/components/ui/markdown.tsx (1)

77-79: Неполное сравнение пропсов в мемоизации.

Функция propsAreEqual сравнивает только content, игнорируя components. Если родительский компонент передаст другой объект components, компонент не перерендерится.

В текущем использовании это безопасно (используется статический INITIAL_COMPONENTS), но может привести к багам при динамической смене компонентов.

♻️ Предлагаемое улучшение
   function propsAreEqual(prevProps, nextProps) {
-    return prevProps.content === nextProps.content
+    return (
+      prevProps.content === nextProps.content &&
+      prevProps.components === nextProps.components
+    )
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/markdown.tsx` around lines 77 - 79, The memoization
comparator propsAreEqual currently only compares prevProps.content and will skip
re-renders when the components prop changes; update propsAreEqual to also
compare the components prop (e.g., return prevProps.content ===
nextProps.content && prevProps.components === nextProps.components) or, if you
need structural equality, use a deep/shallow equality helper (like isEqual or
shallowEqual) to compare prevProps.components and nextProps.components; refer to
the propsAreEqual function and the INITIAL_COMPONENTS constant when making this
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/src/middleware/auth.ts`:
- Around line 5-8: The current timingSafeEqual function leaks length information
by returning early when a.length !== b.length; instead, remove the early length
check and compute fixed-length SHA-256 digests of both inputs, then call
cryptoTimingSafeEqual on the two digest Buffers so comparisons always operate on
equal-length buffers; update the timingSafeEqual implementation to accept
strings, produce sha256 buffers for both (using the existing crypto utilities),
and use cryptoTimingSafeEqual(digestA, digestB) to determine equality.

In `@backend/src/middleware/rate-limit.ts`:
- Around line 53-66: В функциях rateLimit и aiRateLimit нормализуйте и
валидируйте значения из ENV перед передачей в createRateLimiter: вместо прямого
parseInt проверяйте результат через Number.isFinite / !isNaN и что число > 0, и
при неверных значениях подставляйте безопасные fallback-значения (например 60 и
60000 для rateLimit, 15 и 60000 для aiRateLimit); также убедитесь, что windowMs
и max приводятся к целым числам и не равны 0, чтобы избежать передачи NaN/0 в
createRateLimiter и предотвращения утечек/неправильных заголовков.

In `@backend/src/routes/flow.ts`:
- Around line 29-35: The route currently only validates edges when body.edges is
an array, allowing non-array values to pass; update the validation around
body.edges so that if body.edges is present but not an array you immediately
return c.json({ error: 'edges must be an array' }, 400), and when iterating over
body.edges (the existing loop that checks each edge) also enforce that
edge.sourceFrameId and edge.destinationFrameId are strings (e.g., typeof ===
'string' and non-empty) rather than just truthy, keeping the existing error
response format used by c.json.
- Around line 26-27: Validate body.screenshots as a map of frameId->base64
before calling analyzeFlow: ensure body.screenshots is a non-null object and not
an Array (use !Array.isArray), then iterate Object.entries(body.screenshots) and
return 400 if any key is not a non-empty string or any value is not a non-empty
base64 string (e.g. check type === 'string' and a simple base64 pattern or
Buffer/base64 decode attempt). Replace the current loose check in the handler
(the body.screenshots branch) with this stricter validation and return c.json({
error: 'Invalid screenshots' }, 400) when it fails so analyzeFlow receives only
corrected input.
- Around line 21-23: The frame validation loop in backend/src/routes/flow.ts is
too permissive: change the checks inside the for (const frame of body.frames)
loop to require typeof frame.id === 'string' and typeof frame.name === 'string'
and use Number.isFinite(frame.width) and Number.isFinite(frame.height) instead
of truthiness/typeof 'number'; keep the same error response shape, and ensure
buildGraphDescription() continues to receive only validated frames (id/name
strings and finite numeric width/height).

In `@backend/src/routes/page-sweep.ts`:
- Around line 21-24: Проверка фрейма принимает нестроковые id/name и пустой
screenshot; исправьте условие цикла по body.frames так, чтобы вываливать 400
если frame не объект или typeof frame.id !== 'string' или typeof frame.name !==
'string' или typeof frame.screenshot !== 'string' или frame.screenshot.trim()
=== ''. Обновите проверку в том месте, где сейчас используется !frame.id ||
!frame.name || typeof frame.screenshot !== 'string', и используйте явную
проверку типов для frame.id/frame.name и отклоняйте пустые строки для
frame.screenshot (ссылки: body.frames, frame.id, frame.name, frame.screenshot,
тип FrameInput и последующая обработка, ожидающая строки).

In `@backend/src/routes/session.ts`:
- Line 24: The current parsing sets conversationLength from
JSON.parse(session.conversation || '[]') but if the JSON is valid non-array it
becomes undefined; update the try/catch around JSON.parse to assign a numeric
conversationLength: parse the string using JSON.parse(session.conversation ||
'[]'), then if Array.isArray(parsed) set conversationLength = parsed.length else
set conversationLength = 0, and keep the catch block to also set
conversationLength = 0 on parse errors; reference the session.conversation
variable and the conversationLength binding when making this change.

In `@backend/src/utils/sanitize.ts`:
- Around line 15-18: The sanitizeText function doesn't trim whitespace as
described; update sanitizeText to call trim() after stripTags and before slicing
so leading/trailing spaces are removed (e.g., use
stripTags(text).trim().slice(0, maxLen)); keep the existing non-string guard and
MAX_TEXT_LENGTH default, and ensure references to sanitizeText (or any
documentation/AI-summary that claims "trims and sanitizes") are consistent with
the implementation.
- Around line 29-32: wrapUserContent currently interpolates the label into
XML-like tag names without validation, which permits tag-injection; update
wrapUserContent to validate or sanitize the label parameter (e.g., restrict to a
safe regex like /^[A-Za-z0-9_-]+$/ or escape/replace any non-allowed characters)
before constructing `<user_${label}>` and `</user_${label}>`, and fail fast or
substitute a safe default when validation fails; ensure sanitizeText is still
applied only to content.
- Around line 21-27: sanitizeTextArray currently slices before filtering which
can drop valid string items if non-strings appear early; change the operation
order in sanitizeTextArray (function name) to first filter non-string items (use
the existing type guard (item): item is string => typeof item === 'string'),
then slice to maxItems, then map each remaining string through
sanitizeText(item, maxLen) so you keep up to maxItems valid strings rather than
truncating before filtering.

In `@package.json`:
- Line 36: Update the Vitest dependency entry in package.json: replace the
non-existent version specifier "^4.1.0" for the "vitest" dependency with the
published version "^4.0.18" so npm installs succeed; locate the "vitest" key in
the dependencies/devDependencies block and change its version string
accordingly.

In `@ui/src/components/ui/code-block.tsx`:
- Around line 41-52: Асинхронная функция highlight внутри useEffect вызывает
codeToHtml без обработки ошибок и без защиты от гонок: оберните вызов await
codeToHtml(...) в try/catch и логируйте/обрабатывайте ошибку (например,
установить пустой/fallback HTML через setHighlightedHtml), и добавьте механизм
отмены/версионирования результата (например, useRef counter или abort token
захваченный в highlight и проверяемый перед вызовом setHighlightedHtml) чтобы
игнорировать устаревшие асинхронные результаты при быстрых сменах
code/language/theme; обратите внимание на функции/символы highlight, useEffect,
codeToHtml и setHighlightedHtml при внесении правок.
- Around line 25-31: The prop type CodeBlockCodeProps currently extends
React.HTMLProps<HTMLDivElement>, which allows callers to pass children and
dangerouslySetInnerHTML and thus override the component's internal
dangerouslySetInnerHTML via the props spread; change the type to exclude those
dangerous props by using Omit on React.HTMLProps<HTMLDivElement> to remove
'children' and 'dangerouslySetInnerHTML' (i.e., define CodeBlockCodeProps as
your existing fields & Omit<React.HTMLProps<HTMLDivElement>, 'children' |
'dangerouslySetInnerHTML'>) so external callers cannot supply or override those
properties when spreading ...props in the component that uses
CodeBlockCodeProps.

In `@ui/src/components/ui/markdown.tsx`:
- Around line 49-53: The code unsafely casts children to string when rendering
CodeBlockCode; instead, update markdown rendering to safely extract text from
React children (e.g. use React.Children.toArray(children), filter for strings
and join them, or otherwise convert non-string nodes to text) before passing to
CodeBlockCode, so CodeBlockCode receives a real string rather than a blind cast;
adjust any related prop types (CodeBlockCode) if needed to accept the resulting
string and preserve the existing language prop in markdown.tsx.

In `@ui/src/components/ui/scroll-button.tsx`:
- Around line 22-35: The Button is an icon-only control (rendering ChevronDown)
and currently has no accessible label; update the ScrollButton component so
Button receives an aria-label (e.g., "Scroll to bottom") by default and allow
consumers to override it via props (accept a prop like ariaLabel or pass-through
aria-label from props), ensuring the onClick (scrollToBottom), variant, size,
className and other {...props} remain intact so screen readers get the label
while preserving existing behavior.
- Around line 22-33: The Button remains keyboard-focusable and its click handler
can be overridden; update the Button usage so that when isAtBottom is true it is
removed from keyboard/tab order by using the disabled attribute or
aria-hidden="true" (e.g., set disabled={isAtBottom} or aria-hidden={isAtBottom})
and include the visual classes accordingly, and fix the handler override by
moving {...props} before the explicit onClick or by invoking props.onClick
conditionally inside the onClick wrapper so scrollToBottom cannot be silently
replaced; update the Button instance (the Button JSX with props, onClick,
className, isAtBottom, scrollToBottom) to implement these changes.

In `@ui/tailwind.config.js`:
- Around line 135-206: The keyframe blocks (text-blink, bounce-dots, thin-pulse,
pulse-dot, shimmer-text, wave-bars, spinner-fade and shimmer) were defined as
siblings instead of inside the Tailwind `keyframes` object and thus won't be
recognized; move each named keyframe (text-blink, bounce-dots, thin-pulse,
pulse-dot, shimmer-text, wave-bars, spinner-fade, and shimmer) into the existing
`keyframes` property in the Tailwind config, remove the duplicated `shimmer`
definition so only one remains, and keep the exact keyframe names (e.g.,
text-blink, bounce-dots, shimmer) when relocating so animation references
continue to work.

---

Outside diff comments:
In `@backend/src/middleware/rate-limit.ts`:
- Around line 23-25: The IP extraction trusts unverified forwarding headers and
must only accept x-forwarded-for/x-real-ip when the request came through a
trusted proxy: update the ip calculation (the ip variable) to check the peer
address (e.g., c.req.socket.remoteAddress or c.req.ip) against a configured
TRUSTED_PROXIES list and only parse the forwarding headers when the peer is in
that allowlist, otherwise use the remote peer address; also update aiRateLimit()
usage to rely on this validated ip. For the parseInt usages that read env vars
(the parseInt calls used to set limits/thresholds), validate the parsed value
with Number.isFinite/Number.isInteger (or Number.isNaN check) and provide a safe
default or throw a clear error when the env value is invalid so NaN cannot
silently disable the limiter.

In `@backend/src/prompts/brand-consistency.ts`:
- Around line 27-46: The prompt builder still emits unsanitized values (color
keys, typography.heading.family, typography.body.family, and rules[].id); update
serialization to run those through sanitizeText (or sanitizeTextArray where
appropriate) before interpolating. Specifically: when building the colors block,
pass the color key/name and c.hex/c.usage through sanitizeText; when serializing
typography use sanitizeText(brandGuide.typography.heading.family) and
sanitizeText(brandGuide.typography.body.family) and sanitizeText on each weight
string if weights can be arbitrary; and when emitting custom rules ensure r.id
and r.severity (and r.description is already sanitized) are sanitized via
sanitizeText before formatting in brandGuide.rules.map. Ensure you reuse
existing sanitizeText/sanitizeTextArray helpers and preserve the same output
structure.

In `@src/manifest.json`:
- Around line 1-13: There are two differing manifest files (root manifest.json
and src/manifest.json); remove the duplicate or merge them into a single
canonical manifest by reconciling key fields (ensure the desired id value is
kept, and combine networkAccess.allowedDomains so the final manifest includes
all required domains including Anthropic), and make sure api, main, ui,
editorType, permissions and documentAccess are consistent across the project;
update whatever build/deploy references expect the manifest so only the
canonical manifest is used.

---

Nitpick comments:
In `@backend/src/index.ts`:
- Around line 71-79: Replace the nine repeated app.use calls with a single
iterable setup: gather the AI route paths (e.g., '/api/analyze',
'/api/analyze-flow', '/api/analyze-page', '/api/brand-consistency',
'/api/copy-tone', '/api/persona-research', '/api/generate-a11y-spec',
'/api/validate-dark-mode', '/api/cognitive-walkthrough') into an array and
iterate over it to call app.use(path, aiRateLimit()) for each; this keeps the
behavior identical but centralizes the list so future endpoints only need to be
added to the array (look for the repeated app.use and aiRateLimit references to
locate the code).

In `@ui/src/App.tsx`:
- Around line 901-907: The mapping uses an unsafe cast (e as any). Fix by making
LintError include an optional value?: string in the messages.ts type definition
or add a type guard that checks for 'value' on the item before reading it; then
update the mapping in App.tsx (the a11yLint.errors map) to read e.value (or use
the guarded value) without using "as any" so TypeScript knows value is optional
and safe to access.

In `@ui/src/components/ui/loader.tsx`:
- Around line 68-93: The inline ternary chains computing marginLeft and
transformOrigin reduce readability; create a small lookup object (e.g., offsets
or sizeOffsets) keyed by size (same keys as barSizes/sizeClasses) that contains
marginLeft and transformOrigin values, then replace the ternary expressions in
the style block inside the map with offsets[size].marginLeft and
offsets[size].transformOrigin; keep barSizes and sizeClasses as-is and ensure
the new object is defined near them or at top of the component for clarity.

In `@ui/src/components/ui/markdown.tsx`:
- Around line 77-79: The memoization comparator propsAreEqual currently only
compares prevProps.content and will skip re-renders when the components prop
changes; update propsAreEqual to also compare the components prop (e.g., return
prevProps.content === nextProps.content && prevProps.components ===
nextProps.components) or, if you need structural equality, use a deep/shallow
equality helper (like isEqual or shallowEqual) to compare prevProps.components
and nextProps.components; refer to the propsAreEqual function and the
INITIAL_COMPONENTS constant when making this change.

In `@ui/src/components/ui/message.tsx`:
- Around line 108-117: Компонент TooltipProvider сейчас создаётся внутри
MessageAction и будет рендериться для каждого действия — вынесите провайдер на
уровень выше (например в контейнер MessageActions или родительский компонент),
удалите обёртку TooltipProvider из MessageAction (оставьте только Tooltip,
TooltipTrigger и TooltipContent в MessageAction), и убедитесь, что верхний
компонент (MessageActions) оборачивает всех дочерних MessageAction в единый
TooltipProvider чтобы снизить лишние перерендера.
- Around line 47-53: MessageContentProps currently intersects
React.ComponentProps<typeof Markdown> and React.HTMLProps<HTMLDivElement>
allowing invalid combinations; change it to a discriminated union so props are
mutually exclusive: one branch where markdown: true (or markdown === true)
extends React.ComponentProps<typeof Markdown> and another branch where markdown
is absent or false extends React.HTMLProps<HTMLDivElement>, and remove the broad
intersection; update usages that rely on the union to narrow by the markdown
flag (e.g., MessageContentProps with markdown key).

In `@ui/src/components/ui/prompt-input.tsx`:
- Around line 29-37: The default context value for PromptInputContext uses
React.createRef(), which creates a new ref on each render; update the
textareaRef default to a stable ref object like { current: null } instead to
avoid recreating refs when usePromptInput() is used outside a Provider—change
the textareaRef in the PromptInputContext default to { current: null } and keep
the rest of the PromptInputContextType fields unchanged so components reading
textareaRef (e.g., via usePromptInput) receive a stable ref.

In `@ui/src/components/ui/text-shimmer.tsx`:
- Around line 24-31: The class string contains a hardcoded duration token
"animate-[shimmer_4s_infinite_linear]" which conflicts with and misleads
relative to the inline style animationDuration (`duration` prop); remove the
hardcoded "4s" from that class token (e.g., change/remove
"animate-[shimmer_4s_infinite_linear]" so it no longer encodes a duration) and
keep the inline style animationDuration (`animationDuration: \`${duration}s\``)
as the single source of truth; update the class-building expression that
includes className so it no longer emits the "4s" variant.

In `@ui/src/lib/api.ts`:
- Around line 38-44: The current validateScreenshot uses screenshot.length
(base64 characters) against MAX_SCREENSHOT_BYTES, which is wrong because base64
expands size ~4/3; update validateScreenshot to first strip any data URL prefix
(data:*;base64,) then compute the actual byte size from the base64 string by
accounting for padding and conversion (bytes = (base64Length * 3) / 4 - padding)
and compare that byte count to MAX_SCREENSHOT_BYTES; keep the thrown Error
message and the MAX_SCREENSHOT_BYTES constant unchanged and only adjust the size
calculation logic in validateScreenshot.

In `@ui/vite.config.ts`:
- Around line 5-6: В файле есть дублирующий импорт: и named import resolve из
'path' и default import path; используйте существующий named import — удалите
"import path from 'path'" и заменить все вызовы path.resolve() (в частности
текущий вызов path.resolve) на resolve(), чтобы сделать импорт и использование
консистентными (смотрите символы resolve, path и path.resolve в текущем диффе).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 40964206-8ea6-4103-bb6d-de21eb5f0898

📥 Commits

Reviewing files that changed from the base of the PR and between 2447498 and 3914bf5.

⛔ Files ignored due to path filters (4)
  • dist/code.js is excluded by !**/dist/**
  • dist/manifest.json is excluded by !**/dist/**
  • dist/ui.html is excluded by !**/dist/**
  • ui/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (45)
  • backend/package.json
  • backend/src/index.ts
  • backend/src/middleware/auth.ts
  • backend/src/middleware/rate-limit.ts
  • backend/src/prompts/brand-consistency.ts
  • backend/src/prompts/chat-followup.ts
  • backend/src/prompts/cognitive-walkthrough.ts
  • backend/src/prompts/copy-tone.ts
  • backend/src/prompts/persona-research.ts
  • backend/src/routes/dark-mode.ts
  • backend/src/routes/flow.ts
  • backend/src/routes/page-sweep.ts
  • backend/src/routes/session.ts
  • backend/src/services/analyzer.ts
  • backend/src/utils/sanitize.ts
  • manifest.json
  • package.json
  • src/api/providers/openai.ts
  • src/code.ts
  • src/fix/naming-fixer.ts
  • src/manifest.json
  • src/ui/message-handler.ts
  • ui/components.json
  • ui/index.html
  • ui/package.json
  • ui/src/App.tsx
  • ui/src/components/chat/MessageList.tsx
  • ui/src/components/ui/avatar.tsx
  • ui/src/components/ui/button.tsx
  • ui/src/components/ui/chat-container.tsx
  • ui/src/components/ui/code-block.tsx
  • ui/src/components/ui/loader.tsx
  • ui/src/components/ui/markdown.tsx
  • ui/src/components/ui/message.tsx
  • ui/src/components/ui/prompt-input.tsx
  • ui/src/components/ui/scroll-button.tsx
  • ui/src/components/ui/text-shimmer.tsx
  • ui/src/components/ui/textarea.tsx
  • ui/src/components/ui/tooltip.tsx
  • ui/src/lib/api.ts
  • ui/src/lib/utils.ts
  • ui/src/styles/globals.css
  • ui/tailwind.config.js
  • ui/tsconfig.json
  • ui/vite.config.ts
✅ Files skipped from review due to trivial changes (2)
  • src/api/providers/openai.ts
  • src/fix/naming-fixer.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • ui/src/components/chat/MessageList.tsx
  • src/ui/message-handler.ts
  • backend/package.json

Comment thread backend/src/middleware/auth.ts
Comment thread backend/src/middleware/rate-limit.ts
Comment thread backend/src/routes/flow.ts Outdated
Comment on lines +26 to 27
if (!body.screenshots || typeof body.screenshots !== 'object' || Object.keys(body.screenshots).length === 0) {
return c.json({ error: 'Missing screenshots' }, 400);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

screenshots сейчас не проверяется как map frameId -> base64 string.

Проверка на typeof === 'object' и непустые ключи пропускает массивы и объекты с любыми значениями вроде { foo: 123 }. Ошибка тогда всплывёт уже внутри analyzeFlow, хотя её можно детерминированно вернуть как 400 здесь.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/routes/flow.ts` around lines 26 - 27, Validate body.screenshots
as a map of frameId->base64 before calling analyzeFlow: ensure body.screenshots
is a non-null object and not an Array (use !Array.isArray), then iterate
Object.entries(body.screenshots) and return 400 if any key is not a non-empty
string or any value is not a non-empty base64 string (e.g. check type ===
'string' and a simple base64 pattern or Buffer/base64 decode attempt). Replace
the current loose check in the handler (the body.screenshots branch) with this
stricter validation and return c.json({ error: 'Invalid screenshots' }, 400)
when it fails so analyzeFlow receives only corrected input.

Comment thread backend/src/routes/flow.ts
Comment thread ui/src/components/ui/code-block.tsx Outdated
Comment on lines +41 to +52
useEffect(() => {
async function highlight() {
if (!code) {
setHighlightedHtml("<pre><code></code></pre>")
return
}

const html = await codeToHtml(code, { lang: language, theme })
setHighlightedHtml(html)
}
highlight()
}, [code, language, theme])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Добавьте защиту от ошибок и гонки в async-подсветке.

На Line 48 codeToHtml(...) может бросить исключение, сейчас это приведёт к unhandled rejection. Плюс при быстрых изменениях code/language/theme более старый async-результат может перезаписать новый.

💡 Предлагаемый фикс
   useEffect(() => {
+    let cancelled = false
     async function highlight() {
+      setHighlightedHtml(null)
       if (!code) {
-        setHighlightedHtml("<pre><code></code></pre>")
         return
       }

-      const html = await codeToHtml(code, { lang: language, theme })
-      setHighlightedHtml(html)
+      try {
+        const html = await codeToHtml(code, { lang: language, theme })
+        if (!cancelled) {
+          setHighlightedHtml(html)
+        }
+      } catch {
+        if (!cancelled) {
+          setHighlightedHtml(null)
+        }
+      }
     }
     highlight()
+    return () => {
+      cancelled = true
+    }
   }, [code, language, theme])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/code-block.tsx` around lines 41 - 52, Асинхронная
функция highlight внутри useEffect вызывает codeToHtml без обработки ошибок и
без защиты от гонок: оберните вызов await codeToHtml(...) в try/catch и
логируйте/обрабатывайте ошибку (например, установить пустой/fallback HTML через
setHighlightedHtml), и добавьте механизм отмены/версионирования результата
(например, useRef counter или abort token захваченный в highlight и проверяемый
перед вызовом setHighlightedHtml) чтобы игнорировать устаревшие асинхронные
результаты при быстрых сменах code/language/theme; обратите внимание на
функции/символы highlight, useEffect, codeToHtml и setHighlightedHtml при
внесении правок.

Comment on lines +49 to +53
return (
<CodeBlock className={className}>
<CodeBlockCode code={children as string} language={language} />
</CodeBlock>
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Небезопасное приведение типа children as string.

children в ReactMarkdown может быть массивом React-узлов, а не строкой. Приведение children as string может передать некорректные данные в CodeBlockCode.

🔧 Предлагаемое исправление
     const language = extractLanguage(className)
+    const codeString = Array.isArray(children)
+      ? children.join('')
+      : String(children ?? '')

     return (
       <CodeBlock className={className}>
-        <CodeBlockCode code={children as string} language={language} />
+        <CodeBlockCode code={codeString} language={language} />
       </CodeBlock>
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/markdown.tsx` around lines 49 - 53, The code unsafely
casts children to string when rendering CodeBlockCode; instead, update markdown
rendering to safely extract text from React children (e.g. use
React.Children.toArray(children), filter for strings and join them, or otherwise
convert non-string nodes to text) before passing to CodeBlockCode, so
CodeBlockCode receives a real string rather than a blind cast; adjust any
related prop types (CodeBlockCode) if needed to accept the resulting string and
preserve the existing language prop in markdown.tsx.

Comment on lines +22 to +35
<Button
variant={variant}
size={size}
className={cn(
"h-10 w-10 rounded-full transition-all duration-150 ease-out",
!isAtBottom
? "translate-y-0 scale-100 opacity-100"
: "pointer-events-none translate-y-4 scale-95 opacity-0",
className
)}
onClick={() => scrollToBottom()}
{...props}
>
<ChevronDown className="h-5 w-5" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Иконке-кнопке нужен доступный label.

Сейчас внутри только ChevronDown, поэтому скринридер получит безымянную кнопку. Для icon-only control лучше выставить дефолтный aria-label и дать возможность переопределить его через props.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/scroll-button.tsx` around lines 22 - 35, The Button is
an icon-only control (rendering ChevronDown) and currently has no accessible
label; update the ScrollButton component so Button receives an aria-label (e.g.,
"Scroll to bottom") by default and allow consumers to override it via props
(accept a prop like ariaLabel or pass-through aria-label from props), ensuring
the onClick (scrollToBottom), variant, size, className and other {...props}
remain intact so screen readers get the label while preserving existing
behavior.

Comment on lines +22 to +33
<Button
variant={variant}
size={size}
className={cn(
"h-10 w-10 rounded-full transition-all duration-150 ease-out",
!isAtBottom
? "translate-y-0 scale-100 opacity-100"
: "pointer-events-none translate-y-4 scale-95 opacity-0",
className
)}
onClick={() => scrollToBottom()}
{...props}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "scroll-button.tsx" | head -20

Repository: lemone112/figmalint

Length of output: 103


🏁 Script executed:

cat -n ./ui/src/components/ui/scroll-button.tsx

Repository: lemone112/figmalint

Length of output: 1473


🏁 Script executed:

cat -n ./ui/src/components/ui/button.tsx | head -50

Repository: lemone112/figmalint

Length of output: 2236


🏁 Script executed:

rg "ScrollButton" --type tsx --type ts -B 2 -A 2

Repository: lemone112/figmalint

Length of output: 90


🏁 Script executed:

rg "ScrollButton" -B 2 -A 2

Repository: lemone112/figmalint

Length of output: 1203


🏁 Script executed:

rg "from.*scroll-button|import.*ScrollButton" -B 1 -A 1

Repository: lemone112/figmalint

Length of output: 45


Устраните проблемы доступности и переопределения обработчика события.

При скрытом состоянии кнопка остаётся доступна с клавиатуры: pointer-events-none блокирует только события мыши, но не удаляет элемент из tab order. Используйте disabled атрибут или aria-hidden="true" для скрытия от клавиатуры.

Кроме того, порядок props некорректен: onClick идёт раньше {...props}, поэтому если родитель передаст свой onClick, он перепишет scrollToBottom. Переместите {...props} перед явным onClick или добавьте проверку перед вызовом.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/scroll-button.tsx` around lines 22 - 33, The Button
remains keyboard-focusable and its click handler can be overridden; update the
Button usage so that when isAtBottom is true it is removed from keyboard/tab
order by using the disabled attribute or aria-hidden="true" (e.g., set
disabled={isAtBottom} or aria-hidden={isAtBottom}) and include the visual
classes accordingly, and fix the handler override by moving {...props} before
the explicit onClick or by invoking props.onClick conditionally inside the
onClick wrapper so scrollToBottom cannot be silently replaced; update the Button
instance (the Button JSX with props, onClick, className, isAtBottom,
scrollToBottom) to implement these changes.

Comment thread ui/tailwind.config.js Outdated
Migration (prompt-kit components):
- AiMessage: parseBold → MessageContent+Markdown (full GFM rendering)
- InputBar: raw input → PromptInput with auto-resize textarea + Lucide Send icon
- MessageList: manual scrollIntoView → ChatContainerRoot+Content+ScrollAnchor
- User bubble: plain div → Message+MessageContent with proper styling
- StickyHeader: 4 inline SVGs → Lucide icons (Settings, ChevronUp, ChevronDown)
- AnalysisPhaseIndicator: CSS spinner → Loader variant="typing"
- ChatContainer: analyzing spinner → Loader component
- Batch/baseline inline SVGs → Lucide CheckCircle/Save

Bundle optimization:
- Remove Shiki (was adding ~9MB!) — replace with lightweight <pre><code> + copy button
- Remove marked dep — replace lexer with simple split
- Bundle: 10MB → 620KB raw, 176KB gzip

Accessibility (WCAG 2.1 AA):
- aria-live="polite" + aria-relevant="additions" on ChatContainerRoot
- role="status" on analyzing state, phase indicator, QuickActions loading
- aria-label on fieldsets ("Quick actions", "Advanced actions")
- role="alert" + <h2> on ErrorBoundary error state
- aria-label on severity dot in StickyHeader
- sr-only status text on analysis phases
- prefers-reduced-motion global CSS rule

Bug fixes:
- Pass streaming prop to AiMessage (was missing → no typing indicator)
- !min-h-[32px] on textarea (override default 44px)
- text-fg-secondary instead of text-muted-foreground in ChatContainer

Verified: tsc clean, 132 tests pass, builds OK, Snyk 0 issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (6)
ui/src/components/ui/markdown.tsx (1)

75-77: Функция propsAreEqual игнорирует изменения components.

Текущая реализация сравнивает только content, игнорируя components. Если prop components изменится (например, при динамической смене рендереров), блок не перерисуется.

♻️ Предлагаемое исправление
   function propsAreEqual(prevProps, nextProps) {
-    return prevProps.content === nextProps.content
+    return (
+      prevProps.content === nextProps.content &&
+      prevProps.components === nextProps.components
+    )
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/markdown.tsx` around lines 75 - 77, The memo equality
function propsAreEqual currently only compares prevProps.content and ignores
changes to the components prop; update propsAreEqual to also compare
prevProps.components and nextProps.components (shallow reference equality) so
the component re-renders when renderers change—e.g., return prevProps.content
=== nextProps.content && prevProps.components === nextProps.components; if you
need deep comparison for component objects, replace the shallow check with an
appropriate deep-equality utility.
ui/src/components/messages/AiMessage.tsx (1)

18-25: Рассмотрите обработку пустого контента без streaming.

Если streaming=false и content="", компонент отрендерит пустой bubble. Возможно, стоит добавить проверку:

♻️ Опциональная обработка пустого контента
 export default function AiMessage({ content, streaming }: AiMessageProps) {
   if (streaming && !content) {
     return (
       <div className="bg-bg-secondary rounded-xl rounded-bl-sm px-3 py-2 max-w-[90%]">
         <Loader variant="typing" size="sm" />
       </div>
     );
   }

+  if (!content) {
+    return null;
+  }
+
   return (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/messages/AiMessage.tsx` around lines 18 - 25, The component
currently renders an empty bubble when streaming is false and content is an
empty string; update the AiMessage component to guard rendering by checking the
props (e.g., streaming and content) before returning MessageContent: if
streaming === false and content is empty/whitespace, return null or a small
placeholder instead of rendering the MessageContent bubble; locate the AiMessage
function and modify the early-return logic around MessageContent (the
MessageContent JSX) to perform this check and avoid rendering an empty bubble.
ui/src/components/chat/InputBar.tsx (1)

22-29: useCallback: зависимость от value создаёт новую функцию при каждом вводе.

handleSubmit зависит от value, поэтому создаётся новая функция при каждом изменении текста. Это может вызывать ненужные ре-рендеры дочерних компонентов, получающих handleSubmit как prop.

Альтернативный подход — использовать ref для хранения текущего значения:

♻️ Опциональный рефакторинг с useRef
+import { useState, useCallback, useRef } from 'react';
-import { useState, useCallback } from 'react';
...
 export default function InputBar({
   onSend,
   placeholder = 'Ask about this component...',
   disabled = false,
 }: InputBarProps) {
   const [value, setValue] = useState('');
+  const valueRef = useRef(value);
+  valueRef.current = value;

-  const handleSubmit = useCallback(() => {
-    const trimmed = value.trim();
+  const handleSubmit = useCallback(() => {
+    const trimmed = valueRef.current.trim();
     if (!trimmed) return;
     onSend(trimmed);
     setValue('');
-  }, [value, onSend]);
+  }, [onSend]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/chat/InputBar.tsx` around lines 22 - 29, handleSubmit is
recreated on every keystroke because it depends on the state variable value;
switch to using a ref to hold the current input so handleSubmit no longer
depends on value. Add a useRef (e.g., valueRef) and update valueRef.current
inside the input onChange alongside setValue, then redefine handleSubmit to
depend only on onSend (or have no deps) and read/trim valueRef.current, call
onSend(trimmed) and clear both setValue('') and valueRef.current = ''. Update
references to value, setValue, handleSubmit, onSend, and the input onChange to
use the ref accordingly.
ui/src/components/chat/MessageList.tsx (1)

124-138: Множественные type assertions as any снижают типобезопасность.

Несколько компонентов получают data={m.data as any}, что обходит проверку типов TypeScript. Это может привести к runtime-ошибкам, если структура данных не соответствует ожиданиям компонента.

Рекомендуется определить корректные типы для каждого kind в union-типе ChatMessage:

♻️ Предлагаемый подход к типизации
// В lib/messages.ts определить discriminated union:
type ChatMessageContent =
  | { kind: 'design-debt'; data: DesignDebtData }
  | { kind: 'dark-mode'; data: DarkModeData }
  | { kind: 'a11y-spec'; data: A11ySpecData }
  // ... остальные типы

// Тогда TypeScript автоматически сузит тип data:
case 'design-debt':
  return <DesignDebtCard key={msg.id} data={m.data} />; // без as any
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/chat/MessageList.tsx` around lines 124 - 138, The switch in
MessageList.tsx is bypassing TypeScript checks by passing m.data as any to
components (e.g., DesignDebtCard, DarkModeCard, A11ySpecCard,
TokenComplianceCard, BrandConsistencyCard, CopyToneCard, PersonaResearchCard,
AttentionHeatmapCard); define a discriminated union ChatMessage (or
ChatMessageContent) in lib/messages.ts where each variant has a specific kind
string and a typed data payload (e.g., { kind: 'design-debt'; data:
DesignDebtData }, etc.), update the component prop types to accept the
corresponding Data types, and then remove the "as any" casts in MessageList.tsx
so each case returns e.g. <DesignDebtCard key={msg.id} data={m.data} /> relying
on TypeScript narrowing to ensure type safety.
research/figma-api-capabilities-2025.md (1)

602-625: Рассмотрите добавление отметки о дате последней проверки источников.

Документ содержит 22 ссылки на официальную документацию Figma. Для долгосрочного поддержания актуальности исследования полезно фиксировать дату последней верификации источников (например, в формате "Last verified: March 2026"), особенно если список источников планируется регулярно обновляться. Это поможет читателям оценить свежесть информации и предотвратит использование устаревших ссылок.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/figma-api-capabilities-2025.md` around lines 602 - 625, Add a "Last
verified: <Month YYYY>" line under the "## Sources" header to indicate when the
links were last checked; update the Sources block by inserting a single-line
timestamp (e.g., "Last verified: March 2026") immediately beneath "## Sources"
so maintainers can see freshness at a glance, and ensure future updates change
that timestamp when the list of links (the bulleted items under "## Sources") is
modified.
COMPETITIVE_LANDSCAPE_2025_2026.md (1)

5-6: Добавьте маркировку доверия к источникам для ключевых метрик.

На Line 5-6 и в блоке источников на Line 476-483 смешаны первичные и вторичные источники для рыночных цифр. Для управленческих выводов лучше пометить каждую ключевую цифру как High/Medium/Low confidence и указать первичный источник рядом с утверждением.

Also applies to: 476-483

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@COMPETITIVE_LANDSCAPE_2025_2026.md` around lines 5 - 6, Update the key metric
statements (e.g., the line "Market Context: Figma $1.05B revenue (2025), 40.65%
UI/UX market share, 13M+ MAU") to append a confidence label (High/Medium/Low)
for each metric and annotate the primary source immediately after each metric in
parentheses; also edit the sources block (the sources listed around lines
476-483) to mark each source as Primary or Secondary and add a mapping that
indicates which metric(s) each source supports so readers can quickly verify
provenance.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@COMPETITIVE_LANDSCAPE_2025_2026.md`:
- Around line 1-2: Замените все упоминания бренда "FigmaLint" в документе на
текущий нейминг "Bezier" (включая заголовок "FigmaLint Competitive Landscape
Analysis" и все упоминания на строках, указанных в ревью: 14-15, 342-390), либо
в переходный период используйте форму "Bezier (formerly FigmaLint)"; убедитесь,
что вы обновили заголовок документа и все внутренние вхождения точным
совпадением с выбранной формой, включая метаданные и поиск/замену по всему
файлу.

In `@research/designer-pain-points-2026.md`:
- Around line 13-20: В файле отсутствуют пустые строки перед и/или после блоков
таблиц, что вызывает предупреждение markdownlint MD058; исправьте это, добавив
ровно одну пустую строку выше и одну пустую строку ниже каждого
Markdown-табличного блока (например таблицы, начинающейся с строки "| Finding |
Source | Data Point |" и всех аналогичных таблиц в документе), приведите
форматирование единообразно по всему файлу и прогоните markdownlint/CI, чтобы
убедиться, что предупреждение MD058 исчезло.

In `@research/figma-api-capabilities-2025.md`:
- Line 178: В таблице найдите строку содержащую текст "Apr 2025 | Update 108~ |
Annotation categories" и исправьте опечатку, удалив лишний символ "~" — заменить
"Update 108~" на "Update 108" чтобы привести формат к единообразию.
- Around line 244-247: В таблице для строки "File Metadata" добавьте корректный
REST путь вместо общего "GET endpoint": замените текст на "GET
/v1/files/:key/meta" и сохраните существующую область доступа
`file_metadata:read`; обновите соответствующий cell в строке таблицы, где
упомянуты "File Metadata" и "GET endpoint" (ищите по фразам "File Metadata" или
`file_metadata:read`).

In `@research/UX-RESEARCH-SYNTHESIS.md`:
- Around line 158-170: The fenced diagram code blocks in
UX-RESEARCH-SYNTHESIS.md (the blocks beginning with the Phase 1: DESIGN ...
Phase 6: DEV QA diagram and the later block starting “Phase 1: DESIGN + AMBIENT
LINT”) are missing a language tag; update both opening fences to use a language
such as `text` (or `md`) so they become ```text (instead of ```), ensuring
markdownlint MD040 compliance.
- Around line 234-242: Tables in the document lack the required blank line(s)
before and after them (MD058); locate each table by its header rows (e.g., the
row starting "| Touchpoint | Current Pain | FigmaLint Intervention | Time Saved
| Evidence |" and the other table header rows referenced) and ensure there is
exactly one empty line above the table start and one empty line after the table
end; apply this normalization consistently for the tables noted (the ones
beginning at the shown header and the other occurrences) so markdownlint MD058
warnings are resolved.
- Around line 460-461: Условие проверки публикации некорректно: выражение
"getPublishStatusAsync() === 'CHANGED' || 'UNPUBLISHED'" всегда истинно;
замените его на корректную проверку — либо вызовите getPublishStatusAsync() один
раз в переменную (например const status = await getPublishStatusAsync()) и
сравните status === 'CHANGED' || status === 'UNPUBLISHED', либо явно повторите
вызов по обеим сторонам оператора (getPublishStatusAsync() === 'CHANGED' ||
getPublishStatusAsync() === 'UNPUBLISHED'); убедитесь, что логика вокруг
component.description === '' использует эту исправленную проверку.

In `@ui/src/components/ui/markdown.tsx`:
- Around line 15-17: parseMarkdownIntoBlocks currently splits on two or more
newlines which will break fenced code blocks that contain blank lines; update
parseMarkdownIntoBlocks to iterate through the markdown line-by-line, maintain a
state variable (e.g., fenceDelimiter or inFence) that toggles when encountering
fence open/close markers like ``` or ~~~, and only treat two-or-more consecutive
blank lines as a block boundary when not inside a fenced code block; keep the
function name parseMarkdownIntoBlocks and return the same string[] of blocks but
build them by accumulating lines into the current block and pushing when a valid
boundary is found.

In `@ui/src/styles/globals.css`:
- Around line 29-49: The CSS token for destructive foreground is missing which
breaks uses of the class text-destructive-foreground in button.tsx; add a new
CSS variable --destructive-foreground to globals.css (set to
var(--figma-color-text-ondanger or a sensible fallback like `#fff/`#fff) matching
the pattern used for other tokens) and then update tailwind.config.js to include
the destructive token's foreground mapping so the utility
text-destructive-foreground resolves to the new --destructive-foreground
variable.

---

Nitpick comments:
In `@COMPETITIVE_LANDSCAPE_2025_2026.md`:
- Around line 5-6: Update the key metric statements (e.g., the line "Market
Context: Figma $1.05B revenue (2025), 40.65% UI/UX market share, 13M+ MAU") to
append a confidence label (High/Medium/Low) for each metric and annotate the
primary source immediately after each metric in parentheses; also edit the
sources block (the sources listed around lines 476-483) to mark each source as
Primary or Secondary and add a mapping that indicates which metric(s) each
source supports so readers can quickly verify provenance.

In `@research/figma-api-capabilities-2025.md`:
- Around line 602-625: Add a "Last verified: <Month YYYY>" line under the "##
Sources" header to indicate when the links were last checked; update the Sources
block by inserting a single-line timestamp (e.g., "Last verified: March 2026")
immediately beneath "## Sources" so maintainers can see freshness at a glance,
and ensure future updates change that timestamp when the list of links (the
bulleted items under "## Sources") is modified.

In `@ui/src/components/chat/InputBar.tsx`:
- Around line 22-29: handleSubmit is recreated on every keystroke because it
depends on the state variable value; switch to using a ref to hold the current
input so handleSubmit no longer depends on value. Add a useRef (e.g., valueRef)
and update valueRef.current inside the input onChange alongside setValue, then
redefine handleSubmit to depend only on onSend (or have no deps) and read/trim
valueRef.current, call onSend(trimmed) and clear both setValue('') and
valueRef.current = ''. Update references to value, setValue, handleSubmit,
onSend, and the input onChange to use the ref accordingly.

In `@ui/src/components/chat/MessageList.tsx`:
- Around line 124-138: The switch in MessageList.tsx is bypassing TypeScript
checks by passing m.data as any to components (e.g., DesignDebtCard,
DarkModeCard, A11ySpecCard, TokenComplianceCard, BrandConsistencyCard,
CopyToneCard, PersonaResearchCard, AttentionHeatmapCard); define a discriminated
union ChatMessage (or ChatMessageContent) in lib/messages.ts where each variant
has a specific kind string and a typed data payload (e.g., { kind:
'design-debt'; data: DesignDebtData }, etc.), update the component prop types to
accept the corresponding Data types, and then remove the "as any" casts in
MessageList.tsx so each case returns e.g. <DesignDebtCard key={msg.id}
data={m.data} /> relying on TypeScript narrowing to ensure type safety.

In `@ui/src/components/messages/AiMessage.tsx`:
- Around line 18-25: The component currently renders an empty bubble when
streaming is false and content is an empty string; update the AiMessage
component to guard rendering by checking the props (e.g., streaming and content)
before returning MessageContent: if streaming === false and content is
empty/whitespace, return null or a small placeholder instead of rendering the
MessageContent bubble; locate the AiMessage function and modify the early-return
logic around MessageContent (the MessageContent JSX) to perform this check and
avoid rendering an empty bubble.

In `@ui/src/components/ui/markdown.tsx`:
- Around line 75-77: The memo equality function propsAreEqual currently only
compares prevProps.content and ignores changes to the components prop; update
propsAreEqual to also compare prevProps.components and nextProps.components
(shallow reference equality) so the component re-renders when renderers
change—e.g., return prevProps.content === nextProps.content &&
prevProps.components === nextProps.components; if you need deep comparison for
component objects, replace the shallow check with an appropriate deep-equality
utility.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8a834c9b-06e1-4cb7-a8dd-024ffe15b252

📥 Commits

Reviewing files that changed from the base of the PR and between 3914bf5 and 5077468.

⛔ Files ignored due to path filters (2)
  • dist/ui.html is excluded by !**/dist/**
  • ui/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (16)
  • COMPETITIVE_LANDSCAPE_2025_2026.md
  • research/UX-RESEARCH-SYNTHESIS.md
  • research/designer-pain-points-2026.md
  • research/figma-api-capabilities-2025.md
  • ui/package.json
  • ui/src/components/chat/ChatContainer.tsx
  • ui/src/components/chat/InputBar.tsx
  • ui/src/components/chat/MessageList.tsx
  • ui/src/components/chat/StickyHeader.tsx
  • ui/src/components/messages/AiMessage.tsx
  • ui/src/components/shared/ErrorBoundary.tsx
  • ui/src/components/shared/QuickActions.tsx
  • ui/src/components/ui/chat-container.tsx
  • ui/src/components/ui/code-block.tsx
  • ui/src/components/ui/markdown.tsx
  • ui/src/styles/globals.css
🚧 Files skipped from review as they are similar to previous changes (4)
  • ui/src/components/shared/QuickActions.tsx
  • ui/src/components/ui/code-block.tsx
  • ui/src/components/shared/ErrorBoundary.tsx
  • ui/src/components/ui/chat-container.tsx

Comment on lines +1 to +2
# FigmaLint Competitive Landscape Analysis
## Design Linting & AI-Powered Design Quality Tools (2025-2026)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Унифицируйте бренд в документе с текущим неймингом продукта.

На Line 1 и дальше по тексту используется FigmaLint, при том что в PR идёт активный переход на Bezier. Для strategy/go-to-market документа это создаёт терминологический шум и риск двойной коммуникации. Рекомендую привести к единому виду (Bezier или Bezier (formerly FigmaLint) в переходный период).

Also applies to: 14-15, 342-390

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@COMPETITIVE_LANDSCAPE_2025_2026.md` around lines 1 - 2, Замените все
упоминания бренда "FigmaLint" в документе на текущий нейминг "Bezier" (включая
заголовок "FigmaLint Competitive Landscape Analysis" и все упоминания на
строках, указанных в ревью: 14-15, 342-390), либо в переходный период
используйте форму "Bezier (formerly FigmaLint)"; убедитесь, что вы обновили
заголовок документа и все внутренние вхождения точным совпадением с выбранной
формой, включая метаданные и поиск/замену по всему файлу.

Comment on lines +13 to +20
| Finding | Source | Data Point |
|---------|--------|------------|
| Design-implementation inconsistencies | UXTools 2024 Survey | **46.3%** of teams report "significant inconsistencies" between design system specs and coded implementations |
| AI output trust gap | Figma 2025 AI Report | Only **32%** of designers say they can rely on AI output quality; **40%** don't trust AI-generated outputs enough for production |
| AI speeds but doesn't fix quality | State of AI in Design 2025 | **78%** say AI speeds workflows, but only **58%** say it improves quality |
| Handoff time waste | Zeplin / Industry Data | Design-dev handoff challenges eat **4-8 hours per employee per week** |
| Automated accessibility coverage | Industry Consensus | Automated tools catch only **30-40%** of WCAG issues; full compliance needs human review |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Исправьте формат таблиц для прохождения markdownlint (MD058).

В нескольких таблицах нет пустых строк до/после блока таблицы (например, начиная с Line 13, 39, 114 и далее). Это лучше поправить единообразно по всему файлу, чтобы убрать предупреждения линтера и избежать нестабильности CI для docs.

Also applies to: 39-51, 114-124, 151-159, 206-216, 221-227, 240-248, 251-261, 286-304

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 18-18: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/designer-pain-points-2026.md` around lines 13 - 20, В файле
отсутствуют пустые строки перед и/или после блоков таблиц, что вызывает
предупреждение markdownlint MD058; исправьте это, добавив ровно одну пустую
строку выше и одну пустую строку ниже каждого Markdown-табличного блока
(например таблицы, начинающейся с строки "| Finding | Source | Data Point |" и
всех аналогичных таблиц в документе), приведите форматирование единообразно по
всему файлу и прогоните markdownlint/CI, чтобы убедиться, что предупреждение
MD058 исчезло.

| Apr 2024 | Update 91 | Various improvements |
| Nov 2024 | Update 102 | Variable binding for component properties |
| Dec 2024 | Update 104 | Annotations API, rich-text descriptions, prototyping reactions |
| Apr 2025 | Update 108~ | Annotation categories |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Исправьте артефакт в версии обновления.

На Line 178 значение Update 108~ выглядит как опечатка (лишний ~), лучше привести к единообразному формату.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/figma-api-capabilities-2025.md` at line 178, В таблице найдите
строку содержащую текст "Apr 2025 | Update 108~ | Annotation categories" и
исправьте опечатку, удалив лишний символ "~" — заменить "Update 108~" на "Update
108" чтобы привести формат к единообразию.

Comment thread research/figma-api-capabilities-2025.md
Comment on lines +158 to +170
```
Phase 1: DESIGN Phase 2: SELF-REVIEW Phase 3: PEER REVIEW
[Create/iterate] [Manual QA checklist] [Share link + wait]
Time: 30-60 min Time: 1-3 days
Pain: Tedious, inconsistent Pain: Inconsistent feedback
Emotion: Dread, boredom Emotion: Anxiety

Phase 4: REVISION Phase 5: HANDOFF Phase 6: DEV QA
[Fix feedback items] [Mark "Ready for Dev"] [Developer discovers issues]
Time: 1-4 hours Time: 15-30 min Time: 2-8 hours
Pain: Rework feeling Pain: "Did I miss anything?" Pain: Design-code mismatch
Emotion: Frustration Emotion: Uncertainty Emotion: Blame, rework
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Добавьте язык у fenced code blocks (MD040).

Блоки диаграмм на Line 158 и Line 178 оформлены как fenced code без языка. Укажите text (или md) для соответствия markdownlint.

Предлагаемое исправление
-```
+```text
 Phase 1: DESIGN                Phase 2: SELF-REVIEW           Phase 3: PEER REVIEW
 ...
-```
+```

-```
+```text
 Phase 1: DESIGN + AMBIENT LINT
 ...
-```
+```

Also applies to: 178-230

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/UX-RESEARCH-SYNTHESIS.md` around lines 158 - 170, The fenced diagram
code blocks in UX-RESEARCH-SYNTHESIS.md (the blocks beginning with the Phase 1:
DESIGN ... Phase 6: DEV QA diagram and the later block starting “Phase 1: DESIGN
+ AMBIENT LINT”) are missing a language tag; update both opening fences to use a
language such as `text` (or `md`) so they become ```text (instead of ```),
ensuring markdownlint MD040 compliance.

Comment on lines +234 to +242
| Touchpoint | Current Pain | FigmaLint Intervention | Time Saved | Evidence |
|---|---|---|---|---|
| During design | Issues accumulate invisibly | Ambient score badge on selection change | 10 min/session | Audit UX-6 |
| Self-review | 30-60 min manual checklist | Automated scan + batch fix | 20-50 min/review | Pain points Section 4.3 |
| Peer review | Reviewer catches mechanical errors | Mechanical issues pre-resolved | 30 min/review | Pipeline Evaluation value map |
| Revision | Unguided fixing in flat list | Guided navigator with auto-rescan | 15 min/revision cycle | Pipeline Evaluation P2 #17 |
| Handoff | Ambiguous specs, missing docs | Structured export + readiness score | 30 min/handoff | Pain points Section 1.2.4 |
| Dev QA | 46.3% inconsistency rate | Pre-validated token compliance | 60 min/week per dev | UXTools 2024 data |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Нормализуйте отступы вокруг таблиц (MD058).

В таблицах (например, начиная с Line 234, 251, 313, 488) не везде соблюдены пустые строки вокруг таблиц. Это стоит унифицировать по всему файлу, чтобы убрать предупреждения markdownlint и держать docs-пайплайн чистым.

Also applies to: 251-268, 313-319, 488-500

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/UX-RESEARCH-SYNTHESIS.md` around lines 234 - 242, Tables in the
document lack the required blank line(s) before and after them (MD058); locate
each table by its header rows (e.g., the row starting "| Touchpoint | Current
Pain | FigmaLint Intervention | Time Saved | Evidence |" and the other table
header rows referenced) and ensure there is exactly one empty line above the
table start and one empty line after the table end; apply this normalization
consistently for the tables noted (the ones beginning at the shown header and
the other occurrences) so markdownlint MD058 warnings are resolved.

Comment on lines +460 to +461
- Missing description: `component.description === ''` with `getPublishStatusAsync() === 'CHANGED' || 'UNPUBLISHED'`
- Instance swap without `preferredValues`: warning ("Consumers won't know which components to swap in")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -type f -name "UX-RESEARCH-SYNTHESIS.md" 2>/dev/null | head -5

Repository: lemone112/figmalint

Length of output: 45


🏁 Script executed:

fd "UX-RESEARCH-SYNTHESIS.md"

Repository: lemone112/figmalint

Length of output: 96


🏁 Script executed:

git ls-files | grep -i "ux-research"

Repository: lemone112/figmalint

Length of output: 96


🏁 Script executed:

wc -l research/UX-RESEARCH-SYNTHESIS.md

Repository: lemone112/figmalint

Length of output: 100


🏁 Script executed:

sed -n '450,470p' research/UX-RESEARCH-SYNTHESIS.md

Repository: lemone112/figmalint

Length of output: 1783


🏁 Script executed:

sed -n '455,465p' research/UX-RESEARCH-SYNTHESIS.md | cat -n

Repository: lemone112/figmalint

Length of output: 1042


🏁 Script executed:

cat -n research/UX-RESEARCH-SYNTHESIS.md | sed -n '455,465p'

Repository: lemone112/figmalint

Length of output: 1042


Исправьте логическое условие для проверки статуса публикации на строке 460.

Выражение getPublishStatusAsync() === 'CHANGED' || 'UNPUBLISHED' в JavaScript/TypeScript фактически всегда истинно, так как строковый литерал 'UNPUBLISHED' справа от оператора || — это truthy значение. Это опасно для документации: читатели могут скопировать условие как есть.

Предлагаемое исправление
-- Missing description: `component.description === ''` with `getPublishStatusAsync() === 'CHANGED' || 'UNPUBLISHED'`
+- Missing description: `component.description === ''` with `(status === 'CHANGED' || status === 'UNPUBLISHED')`, where `status = await component.getPublishStatusAsync()`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Missing description: `component.description === ''` with `getPublishStatusAsync() === 'CHANGED' || 'UNPUBLISHED'`
- Instance swap without `preferredValues`: warning ("Consumers won't know which components to swap in")
- Missing description: `component.description === ''` with `(status === 'CHANGED' || status === 'UNPUBLISHED')`, where `status = await component.getPublishStatusAsync()`
- Instance swap without `preferredValues`: warning ("Consumers won't know which components to swap in")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/UX-RESEARCH-SYNTHESIS.md` around lines 460 - 461, Условие проверки
публикации некорректно: выражение "getPublishStatusAsync() === 'CHANGED' ||
'UNPUBLISHED'" всегда истинно; замените его на корректную проверку — либо
вызовите getPublishStatusAsync() один раз в переменную (например const status =
await getPublishStatusAsync()) и сравните status === 'CHANGED' || status ===
'UNPUBLISHED', либо явно повторите вызов по обеим сторонам оператора
(getPublishStatusAsync() === 'CHANGED' || getPublishStatusAsync() ===
'UNPUBLISHED'); убедитесь, что логика вокруг component.description === ''
использует эту исправленную проверку.

Comment on lines +15 to +17
function parseMarkdownIntoBlocks(markdown: string): string[] {
return markdown.split(/\n{2,}/).filter(Boolean)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Разбиение по \n{2,} может разбить блоки кода с пустыми строками.

Функция parseMarkdownIntoBlocks разделяет markdown по двум или более переносам строк. Это может некорректно разбить блоки кода (````code```), содержащие пустые строки внутри, на отдельные части, нарушив их рендеринг.

🔍 Пример проблемы

Входной markdown:

```js
function foo() {

  return 42
}
```

Будет разбит на два блока вместо одного, что сломает подсветку синтаксиса.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/components/ui/markdown.tsx` around lines 15 - 17,
parseMarkdownIntoBlocks currently splits on two or more newlines which will
break fenced code blocks that contain blank lines; update
parseMarkdownIntoBlocks to iterate through the markdown line-by-line, maintain a
state variable (e.g., fenceDelimiter or inFence) that toggles when encountering
fence open/close markers like ``` or ~~~, and only treat two-or-more consecutive
blank lines as a block boundary when not inside a fenced code block; keep the
function name parseMarkdownIntoBlocks and return the same string[] of blocks but
build them by accumulating lines into the current block and pushing when a valid
boundary is found.

Comment thread ui/src/styles/globals.css
Comment on lines +29 to +49
/* ── shadcn variables → Figma aliases ── */
--background: var(--figma-color-bg, #ffffff);
--foreground: var(--figma-color-text, #333333);
--card: var(--figma-color-bg, #ffffff);
--card-foreground: var(--figma-color-text, #333333);
--popover: var(--figma-color-bg, #ffffff);
--popover-foreground: var(--figma-color-text, #333333);
--primary: var(--figma-color-bg-brand, #0d99ff);
--primary-foreground: var(--figma-color-text-onbrand, #ffffff);
--secondary: var(--figma-color-bg-secondary, #f5f5f5);
--secondary-foreground: var(--figma-color-text, #333333);
--muted: var(--figma-color-bg-tertiary, #e6e6e6);
--muted-foreground: var(--figma-color-text-secondary, #666666);
--accent: var(--figma-color-bg-hover, rgba(0,0,0,0.06));
--accent-foreground: var(--figma-color-text, #333333);
--destructive: var(--figma-color-text-danger, #f24822);
--border: var(--figma-color-border, #e6e6e6);
--input: var(--figma-color-border, #e6e6e6);
--ring: var(--figma-color-bg-brand, #0d99ff);
--radius: 0.5rem;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Проверка определения токенов в Tailwind-конфиге =="
fd 'tailwind.config.*' -x sh -c 'echo "--- $1 ---"; sed -n "1,260p" "$1"' sh {}

echo "== Поиск использования destructive токенов в UI =="
rg -nP --type=ts --type=tsx --type=css -C2 '\b(bg|text|border)-destructive(?:-foreground)?\b|var\(--destructive-foreground\)'

Repository: lemone112/figmalint

Length of output: 4646


🏁 Script executed:

# Поиск компонентов, использующих destructive-классы
rg -n '(bg|text|border)-destructive|destructive-foreground' --type-list | head -20

Repository: lemone112/figmalint

Length of output: 452


🏁 Script executed:

# Более тщательный поиск по расширениям файлов
fd '\.(tsx?|jsx?|css)$' -x rg -l 'destructive' {} \; 2>/dev/null

Repository: lemone112/figmalint

Length of output: 192


🏁 Script executed:

# Проверить, использует ли какой-то компонент текст поверх bg-destructive
rg -n 'bg-destructive' -A 3 -B 1

Repository: lemone112/figmalint

Length of output: 50376


Добавьте --destructive-foreground в CSS алиасы Tailwind-токенов.

Компонент button.tsx использует класс text-destructive-foreground (строка 14), но переменная --destructive-foreground не определена в globals.css. Это вызовет ошибку при применении деструктивного стиля к кнопкам и прочим компонентам с семантикой удаления/опасности.

Рекомендуемое исправление
     --destructive: var(--figma-color-text-danger, `#f24822`);
+    --destructive-foreground: var(--figma-color-text-onbrand, `#ffffff`);

Также обновите tailwind.config.js, чтобы добавить foreground в деструктивный токен:

     destructive: {
-      DEFAULT: 'var(--destructive)'
+      DEFAULT: 'var(--destructive)',
+      foreground: 'var(--destructive-foreground)'
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/styles/globals.css` around lines 29 - 49, The CSS token for
destructive foreground is missing which breaks uses of the class
text-destructive-foreground in button.tsx; add a new CSS variable
--destructive-foreground to globals.css (set to var(--figma-color-text-ondanger
or a sensible fallback like `#fff/`#fff) matching the pattern used for other
tokens) and then update tailwind.config.js to include the destructive token's
foreground mapping so the utility text-destructive-foreground resolves to the
new --destructive-foreground variable.

Security:
- auth.ts: hash inputs with SHA-256 before timingSafeEqual (no length leak)
- rate-limit.ts: validate parseInt results with Number.isFinite (prevent NaN disabling limiter)
- sanitize.ts: validate wrapUserContent label against /^[A-Za-z0-9_-]+$/ (prevent tag injection)
- sanitize.ts: add .trim() to sanitizeText after stripTags
- brand-consistency.ts: sanitize color keys, font families, rule IDs + severity

Validation:
- flow.ts: strict typeof checks on frame/edge fields, reject non-array edges
- session.ts: Array.isArray check before .length on parsed conversation
- api.ts: fix base64 size validation (compute decoded byte size, not string length)

UI/Config:
- tailwind.config.js: move 7 keyframes into keyframes object (were siblings), remove duplicate shimmer
- src/manifest.json: sync id + allowedDomains with root manifest
- scroll-button.tsx: add aria-label="Scroll to bottom"
- vite.config.ts: remove duplicate path import
- research/figma-api-capabilities-2025.md: fix File Metadata API path

Verified: tsc clean, 194 tests pass, builds OK, Snyk 0 issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ui/src/lib/api.ts (2)

246-283: ⚠️ Potential issue | 🟡 Minor

Отсутствует валидация скриншотов в analyzePageSweep.

Функция принимает массив фреймов, каждый из которых содержит screenshot, но validateScreenshot не вызывается для них. Это создаёт несоответствие с другими эндпоинтами (analyzeComponent, analyzeBrandConsistency, и т.д.), где валидация выполняется.

Злоумышленник или ошибка в клиентском коде могут отправить слишком большие скриншоты, что приведёт к проблемам с памятью или превышению лимитов на стороне сервера.

🛡️ Предлагаемое исправление
 }): Promise<{
   // ... return type
 }> {
+  for (const frame of data.frames) {
+    validateScreenshot(frame.screenshot);
+  }
   const resp = await fetchWithTimeout(`${backendUrl}/api/analyze-page`, {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/lib/api.ts` around lines 246 - 283, analyzePageSweep currently posts
frames without validating each frame.screenshot; call the existing
validateScreenshot function for every frame (e.g., iterate data.frames and run
validateScreenshot(frame.screenshot)) and reject/throw or return a clear error
when validation fails before calling fetchWithTimeout; ensure you reference
validateScreenshot and analyzePageSweep so the check happens early (and
preferably trim or reject oversized screenshots) to match behavior of
analyzeComponent/analyzeBrandConsistency.

296-325: ⚠️ Potential issue | 🟡 Minor

Отсутствует валидация скриншотов в analyzeFlow.

Аналогично analyzePageSweep, эта функция принимает screenshots: Record<string, string>, но не валидирует их размер перед отправкой. Рекомендуется добавить валидацию для консистентности с остальными эндпоинтами.

🛡️ Предлагаемое исправление
 }): Promise<{
   // ... return type
 }> {
+  for (const screenshot of Object.values(data.screenshots)) {
+    validateScreenshot(screenshot);
+  }
   const resp = await fetchWithTimeout(`${backendUrl}/api/analyze-flow`, {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/lib/api.ts` around lines 296 - 325, The analyzeFlow function sends
screenshots without validating their size/format like analyzePageSweep does; add
a validation step (reuse or implement validateScreenshots) that iterates over
the screenshots record passed into analyzeFlow and ensures each value is a
base64 string and below the agreed size limit (e.g., ~3MB / character length
threshold), and either strip/omit oversized entries or throw a clear error; call
this validation before JSON.stringify(data) in analyzeFlow (near
fetchWithTimeout/backendUrl) so only validated screenshots are sent to the
/api/analyze-flow endpoint.
♻️ Duplicate comments (2)
backend/src/routes/flow.ts (1)

26-27: ⚠️ Potential issue | 🟠 Major

screenshots по-прежнему не валидируется как словарь frameId -> base64 string.

Проверка typeof body.screenshots !== 'object' пропускает массивы (typeof [] === 'object'), а значения не проверяются на тип string. Это позволяет передать { foo: 123 } или [...], что приведёт к ошибкам внутри analyzeFlow, хотя их можно детерминированно отклонить здесь с кодом 400.

,

🔧 Предлагаемое исправление
-    if (!body.screenshots || typeof body.screenshots !== 'object' || Object.keys(body.screenshots).length === 0) {
+    if (
+      !body.screenshots ||
+      typeof body.screenshots !== 'object' ||
+      Array.isArray(body.screenshots) ||
+      Object.keys(body.screenshots).length === 0
+    ) {
+      return c.json({ error: 'Missing screenshots' }, 400);
+    }
+    for (const [frameId, value] of Object.entries(body.screenshots)) {
+      if (typeof frameId !== 'string' || !frameId || typeof value !== 'string' || !value) {
+        return c.json({ error: 'Each screenshot must be a non-empty base64 string keyed by frameId' }, 400);
+      }
+    }
-      return c.json({ error: 'Missing screenshots' }, 400);
-    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/routes/flow.ts` around lines 26 - 27, Валидация поля
body.screenshots в обработчике маршрута (flow.ts) неверна: поправьте проверку
так, чтобы она отклоняла массивы и проверяла, что screenshots — плоский словарь
frameId->base64 string; например, сначала отвергнуть
Array.isArray(body.screenshots), затем пройти по
Object.entries(body.screenshots) и вернуть 400 если любой ключ не строка или
любое значение не непустая строка (опционально проверить соответствие base64
паттерну). Обновите сообщение об ошибке и убедитесь, что analyzeFlow будет
получать только корректную структуру frameId->base64.
research/figma-api-capabilities-2025.md (1)

178-178: ⚠️ Potential issue | 🟡 Minor

Исправьте артефакт в номере версии обновления.

Значение Update 108~ содержит лишний символ ~. Все остальные строки таблицы используют формат Update NNN без символа тильды. Необходимо привести к единообразному формату.

🤖 Предлагаемое исправление
-| Apr 2025 | Update 108~ | Annotation categories |
+| Apr 2025 | Update 108 | Annotation categories |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/figma-api-capabilities-2025.md` at line 178, В строке таблицы, где
указано "Update 108~" (строка с датой "Apr 2025"), удалите лишний символ тильды
и измените значение на "Update 108" чтобы соответствовать формату остальных
записей; отредактируйте соответствующую ячейку таблицы (значение в колонке
версии — "Update 108~") заменив на "Update 108".
🧹 Nitpick comments (7)
research/figma-api-capabilities-2025.md (4)

18-41: Рассмотрите добавление пустых строк вокруг таблиц.

Markdownlint рекомендует окружать таблицы пустыми строками для улучшения читаемости и соответствия стандартным практикам форматирования Markdown. Это относится к таблицам на строках 18, 26, 34, 41 и аналогичным таблицам в остальной части документа.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/figma-api-capabilities-2025.md` around lines 18 - 41, The Markdown
tables (e.g., the tables listing methods like
getVariableByIdAsync/getLocalVariablesAsync, the Creation table with
createVariable/createVariableCollection/createVariableAlias, the Binding Helpers
table with setBoundVariableForPaint/setBoundVariableForEffect, and the "Library
/ Extended Collections" table) need a blank line before and after each table;
update the document by inserting an empty line above the table header and one
below the table end for each occurrence to satisfy markdownlint and improve
readability.

572-585: Добавьте спецификатор языка для блока кода.

Блок кода с REST API эндпоинтами не имеет указания языка. Для единообразия с другими блоками кода в документе рекомендуется добавить спецификатор (например, http или text).

♻️ Предлагаемое исправление
-```
+```http
 GET /v1/files/:key                              // Full file data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/figma-api-capabilities-2025.md` around lines 572 - 585, The fenced
code block containing the REST endpoints (starting with "GET /v1/files/:key" and
ending with "POST /v2/webhooks") needs a language specifier for consistency;
edit that code fence to include a language token such as "http" (e.g., change
``` to ```http) so the block is highlighted and matches other code blocks in the
document.

79-102: Добавьте спецификатор языка для блока кода.

Блок кода, перечисляющий значения VariableScope, не имеет указания языка. Рекомендуется добавить спецификатор (например, typescript или text) для улучшения читаемости и соответствия лучшим практикам Markdown.

♻️ Предлагаемое исправление
-```
+```typescript
 ALL_SCOPES          // Special: shown everywhere
 TEXT_CONTENT         // Text node content
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/figma-api-capabilities-2025.md` around lines 79 - 102, The code
fence showing VariableScope constants (e.g., ALL_SCOPES, TEXT_CONTENT,
CORNER_RADIUS, etc.) lacks a language specifier; update the opening fence to
include a language like "typescript" (or "text") so the block begins with
```typescript and leave the contents unchanged to improve Markdown rendering and
syntax highlighting.

392-396: Добавьте спецификатор языка для блока кода.

Блок кода с REST API эндпоинтами не имеет указания языка. Рекомендуется добавить спецификатор (например, http или text) для улучшения читаемости.

♻️ Предлагаемое исправление
-```
+```http
 GET /v1/files/:file_key — Full file data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research/figma-api-capabilities-2025.md` around lines 392 - 396, В блоке с
REST-эндпоинтами (строки содержащие "GET /v1/files/:file_key", "GET
/v1/files/:file_key/nodes?ids=x,y,z" и "GET /v1/teams/:team_id/components")
добавьте спецификатор языка для fenced code block (например ```http или ```text)
перед началом блока и оставьте закрывающий ``` после него, чтобы код выделялся
корректно и улучшалась читаемость.
ui/src/lib/api.ts (3)

221-229: fetchReferoData и checkHealth не используют fetchWithTimeout.

Эти функции выполняют сетевые запросы без таймаута. Для health-check это особенно важно — запрос без таймаута может блокировать UI при сетевых проблемах.

♻️ Предлагаемое исправление
 export async function fetchReferoData(sessionId: string): Promise<{ ready: boolean; data?: any }> {
   try {
-    const resp = await fetch(`${backendUrl}/api/session/${sessionId}/refero`, { method: 'GET' });
+    const resp = await fetchWithTimeout(`${backendUrl}/api/session/${sessionId}/refero`, { method: 'GET' }, 10_000);
     if (!resp.ok) return { ready: false };
     return resp.json();
   } catch {
     return { ready: false };
   }
 }

 export async function checkHealth(): Promise<boolean> {
   try {
-    const resp = await fetch(`${backendUrl}/api/health`, { method: 'GET' });
+    const resp = await fetchWithTimeout(`${backendUrl}/api/health`, { method: 'GET' }, 5_000);
     return resp.ok;
   } catch {
     return false;
   }
 }

Also applies to: 234-241

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/lib/api.ts` around lines 221 - 229, The fetchReferoData and
checkHealth functions currently call fetch without a timeout, which can hang the
UI; update both (fetchReferoData and checkHealth) to call the shared
fetchWithTimeout helper instead of fetch, passing an appropriate timeout value
(e.g., a few seconds) and propagating/handling the timeout error similarly to
existing error handling so the functions still return { ready: false } on
failure; ensure you replace the raw fetch(...) calls with await
fetchWithTimeout(...) and keep the existing response.ok checks and JSON return
behavior.

20-36: Потенциальная утечка памяти: слушатель abort не удаляется при успешном завершении запроса.

На строке 29 добавляется слушатель события abort к переданному existingSignal. Хотя используется { once: true }, если сигнал никогда не срабатывает (что является нормальным сценарием при успешном fetch), слушатель остаётся прикреплённым к existingSignal и удерживает ссылку на локальный controller. Если existingSignal долгоживущий (например, глобальный контроллер приложения), это приводит к накоплению "мёртвых" контроллеров.

♻️ Предлагаемое исправление
 function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = API_TIMEOUT_MS): Promise<Response> {
   const controller = new AbortController();
   const existingSignal = options.signal;
+  let abortHandler: (() => void) | undefined;
 
   if (existingSignal) {
-    // If already aborted, abort immediately
     if (existingSignal.aborted) {
       controller.abort(existingSignal.reason);
     } else {
-      existingSignal.addEventListener('abort', () => controller.abort(existingSignal.reason), { once: true });
+      abortHandler = () => controller.abort(existingSignal.reason);
+      existingSignal.addEventListener('abort', abortHandler, { once: true });
     }
   }
 
   const timeout = setTimeout(() => controller.abort(new DOMException('Request timed out', 'TimeoutError')), timeoutMs);
 
-  return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timeout));
+  return fetch(url, { ...options, signal: controller.signal }).finally(() => {
+    clearTimeout(timeout);
+    if (abortHandler && existingSignal) {
+      existingSignal.removeEventListener('abort', abortHandler);
+    }
+  });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/lib/api.ts` around lines 20 - 36, fetchWithTimeout attaches an abort
listener to existingSignal that can retain references to the local controller if
the signal never fires; fix by registering a named handler (e.g. const onAbort =
() => controller.abort(existingSignal.reason)) when adding
existingSignal.addEventListener('abort', onAbort) and then remove that listener
in the cleanup path (the finally block after fetch) via
existingSignal.removeEventListener('abort', onAbort); keep the immediate-aborted
branch unchanged and only install/remove the handler when existingSignal is
present to avoid the memory leak.

137-151: streamChat не использует таймаут — уточните, является ли это намеренным.

В отличие от остальных API-вызовов, streamChat использует прямой fetch без fetchWithTimeout. Для streaming-соединений это может быть намеренным, так как передача данных может занимать длительное время. Однако отсутствие таймаута на установление соединения (connection timeout) может привести к зависанию при сетевых проблемах.

Рассмотрите добавление таймаута хотя бы на фазу установления соединения, или добавьте комментарий, поясняющий намеренный пропуск таймаута для streaming.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/lib/api.ts` around lines 137 - 151, The streamChat function currently
uses a plain fetch to `${backendUrl}/api/stream/${sessionId}` which omits any
connection timeout; either wrap the initial request in the existing
fetchWithTimeout helper or implement an AbortController-based short timeout for
the connection phase inside streamChat so the request fails quickly on network
hangs while preserving streaming of response chunks; update streamChat to call
fetchWithTimeout (or create a temporary controller that aborts after X seconds
but does not abort while reading the stream) and/or add a clear comment in
streamChat explaining the intentional lack of full request timeout if you decide
to keep the current behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/src/middleware/rate-limit.ts`:
- Line 58: The current rateLimit()/aiRateLimit() calls call
createRateLimiter(...) each time which spins up a new store and setInterval per
invocation; cache and reuse a single middleware instance per limiter
configuration/type instead. Change rateLimit and aiRateLimit to look up a shared
Map keyed by the limiter type or config (e.g., "ai" or a stringified
{max,windowMs}), create the middleware via createRateLimiter(...) only if
absent, store it, and return the cached middleware for subsequent calls so
store/setInterval are not duplicated (refer to the rateLimit, aiRateLimit
functions and createRateLimiter).

In `@ui/tailwind.config.js`:
- Around line 91-198: The keyframes block is defined at theme.keyframes which
overrides Tailwind's defaults (removing spin, pulse, ping, bounce); move the
entire keyframes object into theme.extend.keyframes so your custom keyframes
merge with built-ins. Update the config by relocating the current keyframes
declaration under the existing extend object (reference: theme.extend.keyframes)
so utilities like animate-spin and animate-pulse (and other default animations)
continue to work alongside your custom animations.

---

Outside diff comments:
In `@ui/src/lib/api.ts`:
- Around line 246-283: analyzePageSweep currently posts frames without
validating each frame.screenshot; call the existing validateScreenshot function
for every frame (e.g., iterate data.frames and run
validateScreenshot(frame.screenshot)) and reject/throw or return a clear error
when validation fails before calling fetchWithTimeout; ensure you reference
validateScreenshot and analyzePageSweep so the check happens early (and
preferably trim or reject oversized screenshots) to match behavior of
analyzeComponent/analyzeBrandConsistency.
- Around line 296-325: The analyzeFlow function sends screenshots without
validating their size/format like analyzePageSweep does; add a validation step
(reuse or implement validateScreenshots) that iterates over the screenshots
record passed into analyzeFlow and ensures each value is a base64 string and
below the agreed size limit (e.g., ~3MB / character length threshold), and
either strip/omit oversized entries or throw a clear error; call this validation
before JSON.stringify(data) in analyzeFlow (near fetchWithTimeout/backendUrl) so
only validated screenshots are sent to the /api/analyze-flow endpoint.

---

Duplicate comments:
In `@backend/src/routes/flow.ts`:
- Around line 26-27: Валидация поля body.screenshots в обработчике маршрута
(flow.ts) неверна: поправьте проверку так, чтобы она отклоняла массивы и
проверяла, что screenshots — плоский словарь frameId->base64 string; например,
сначала отвергнуть Array.isArray(body.screenshots), затем пройти по
Object.entries(body.screenshots) и вернуть 400 если любой ключ не строка или
любое значение не непустая строка (опционально проверить соответствие base64
паттерну). Обновите сообщение об ошибке и убедитесь, что analyzeFlow будет
получать только корректную структуру frameId->base64.

In `@research/figma-api-capabilities-2025.md`:
- Line 178: В строке таблицы, где указано "Update 108~" (строка с датой "Apr
2025"), удалите лишний символ тильды и измените значение на "Update 108" чтобы
соответствовать формату остальных записей; отредактируйте соответствующую ячейку
таблицы (значение в колонке версии — "Update 108~") заменив на "Update 108".

---

Nitpick comments:
In `@research/figma-api-capabilities-2025.md`:
- Around line 18-41: The Markdown tables (e.g., the tables listing methods like
getVariableByIdAsync/getLocalVariablesAsync, the Creation table with
createVariable/createVariableCollection/createVariableAlias, the Binding Helpers
table with setBoundVariableForPaint/setBoundVariableForEffect, and the "Library
/ Extended Collections" table) need a blank line before and after each table;
update the document by inserting an empty line above the table header and one
below the table end for each occurrence to satisfy markdownlint and improve
readability.
- Around line 572-585: The fenced code block containing the REST endpoints
(starting with "GET /v1/files/:key" and ending with "POST /v2/webhooks") needs a
language specifier for consistency; edit that code fence to include a language
token such as "http" (e.g., change ``` to ```http) so the block is highlighted
and matches other code blocks in the document.
- Around line 79-102: The code fence showing VariableScope constants (e.g.,
ALL_SCOPES, TEXT_CONTENT, CORNER_RADIUS, etc.) lacks a language specifier;
update the opening fence to include a language like "typescript" (or "text") so
the block begins with ```typescript and leave the contents unchanged to improve
Markdown rendering and syntax highlighting.
- Around line 392-396: В блоке с REST-эндпоинтами (строки содержащие "GET
/v1/files/:file_key", "GET /v1/files/:file_key/nodes?ids=x,y,z" и "GET
/v1/teams/:team_id/components") добавьте спецификатор языка для fenced code
block (например ```http или ```text) перед началом блока и оставьте закрывающий
``` после него, чтобы код выделялся корректно и улучшалась читаемость.

In `@ui/src/lib/api.ts`:
- Around line 221-229: The fetchReferoData and checkHealth functions currently
call fetch without a timeout, which can hang the UI; update both
(fetchReferoData and checkHealth) to call the shared fetchWithTimeout helper
instead of fetch, passing an appropriate timeout value (e.g., a few seconds) and
propagating/handling the timeout error similarly to existing error handling so
the functions still return { ready: false } on failure; ensure you replace the
raw fetch(...) calls with await fetchWithTimeout(...) and keep the existing
response.ok checks and JSON return behavior.
- Around line 20-36: fetchWithTimeout attaches an abort listener to
existingSignal that can retain references to the local controller if the signal
never fires; fix by registering a named handler (e.g. const onAbort = () =>
controller.abort(existingSignal.reason)) when adding
existingSignal.addEventListener('abort', onAbort) and then remove that listener
in the cleanup path (the finally block after fetch) via
existingSignal.removeEventListener('abort', onAbort); keep the immediate-aborted
branch unchanged and only install/remove the handler when existingSignal is
present to avoid the memory leak.
- Around line 137-151: The streamChat function currently uses a plain fetch to
`${backendUrl}/api/stream/${sessionId}` which omits any connection timeout;
either wrap the initial request in the existing fetchWithTimeout helper or
implement an AbortController-based short timeout for the connection phase inside
streamChat so the request fails quickly on network hangs while preserving
streaming of response chunks; update streamChat to call fetchWithTimeout (or
create a temporary controller that aborts after X seconds but does not abort
while reading the stream) and/or add a clear comment in streamChat explaining
the intentional lack of full request timeout if you decide to keep the current
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 31018c1c-c24d-4e9d-a673-eab311b1f8d7

📥 Commits

Reviewing files that changed from the base of the PR and between 5077468 and b8a8061.

⛔ Files ignored due to path filters (1)
  • dist/ui.html is excluded by !**/dist/**
📒 Files selected for processing (12)
  • backend/src/middleware/auth.ts
  • backend/src/middleware/rate-limit.ts
  • backend/src/prompts/brand-consistency.ts
  • backend/src/routes/flow.ts
  • backend/src/routes/session.ts
  • backend/src/utils/sanitize.ts
  • research/figma-api-capabilities-2025.md
  • src/manifest.json
  • ui/src/components/ui/scroll-button.tsx
  • ui/src/lib/api.ts
  • ui/tailwind.config.js
  • ui/vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • backend/src/middleware/auth.ts
  • backend/src/routes/session.ts
  • backend/src/prompts/brand-consistency.ts
  • src/manifest.json
  • ui/src/components/ui/scroll-button.tsx
  • ui/vite.config.ts
  • backend/src/utils/sanitize.ts

const rawWindow = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10);
const max = Number.isFinite(rawMax) && rawMax > 0 ? rawMax : 60;
const windowMs = Number.isFinite(rawWindow) && rawWindow > 0 ? rawWindow : 60000;
return createRateLimiter(max, windowMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Не создавайте новый лимитер на каждый вызов rateLimit()/aiRateLimit().

Сейчас каждый return createRateLimiter(...) создаёт отдельные store и setInterval. Если aiRateLimit() подключён к нескольким AI-роутам, квота становится независимой для каждого роута, поэтому более строгий лимит можно обойти, переключаясь между endpoint'ами; заодно дублируются таймеры cleanup. Лучше кэшировать по одному экземпляру middleware на тип лимитера.

💡 Вариант исправления
+function readPositiveInt(value: string | undefined, fallback: number): number {
+  const parsed = parseInt(value ?? String(fallback), 10);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+let defaultRateLimitMiddleware: MiddlewareHandler | undefined;
+let defaultAiRateLimitMiddleware: MiddlewareHandler | undefined;
+
 export function rateLimit(): MiddlewareHandler {
-  const rawMax = parseInt(process.env.RATE_LIMIT_MAX || '60', 10);
-  const rawWindow = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10);
-  const max = Number.isFinite(rawMax) && rawMax > 0 ? rawMax : 60;
-  const windowMs = Number.isFinite(rawWindow) && rawWindow > 0 ? rawWindow : 60000;
-  return createRateLimiter(max, windowMs);
+  if (!defaultRateLimitMiddleware) {
+    defaultRateLimitMiddleware = createRateLimiter(
+      readPositiveInt(process.env.RATE_LIMIT_MAX, 60),
+      readPositiveInt(process.env.RATE_LIMIT_WINDOW_MS, 60000),
+    );
+  }
+  return defaultRateLimitMiddleware;
 }
 
 export function aiRateLimit(): MiddlewareHandler {
-  const rawMax = parseInt(process.env.AI_RATE_LIMIT_MAX || '15', 10);
-  const rawWindow = parseInt(process.env.AI_RATE_LIMIT_WINDOW_MS || '60000', 10);
-  const max = Number.isFinite(rawMax) && rawMax > 0 ? rawMax : 15;
-  const windowMs = Number.isFinite(rawWindow) && rawWindow > 0 ? rawWindow : 60000;
-  return createRateLimiter(max, windowMs);
+  if (!defaultAiRateLimitMiddleware) {
+    defaultAiRateLimitMiddleware = createRateLimiter(
+      readPositiveInt(process.env.AI_RATE_LIMIT_MAX, 15),
+      readPositiveInt(process.env.AI_RATE_LIMIT_WINDOW_MS, 60000),
+    );
+  }
+  return defaultAiRateLimitMiddleware;
 }

Also applies to: 70-70

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/middleware/rate-limit.ts` at line 58, The current
rateLimit()/aiRateLimit() calls call createRateLimiter(...) each time which
spins up a new store and setInterval per invocation; cache and reuse a single
middleware instance per limiter configuration/type instead. Change rateLimit and
aiRateLimit to look up a shared Map keyed by the limiter type or config (e.g.,
"ai" or a stringified {max,windowMs}), create the middleware via
createRateLimiter(...) only if absent, store it, and return the cached
middleware for subsequent calls so store/setInterval are not duplicated (refer
to the rateLimit, aiRateLimit functions and createRateLimiter).

Comment thread ui/tailwind.config.js
Comment on lines +91 to +198
keyframes: {
typing: {
'0%, 100%': {
transform: 'translateY(0)',
opacity: '0.5'
},
'50%': {
transform: 'translateY(-2px)',
opacity: '1'
}
},
'loading-dots': {
'0%, 100%': {
opacity: '0'
},
'50%': {
opacity: '1'
}
},
wave: {
'0%, 100%': {
transform: 'scaleY(1)'
},
'50%': {
transform: 'scaleY(0.6)'
}
},
blink: {
'0%, 100%': {
opacity: '1'
},
'50%': {
opacity: '0'
}
},
shimmer: {
'0%': {
backgroundPosition: '200% 50%'
},
'100%': {
backgroundPosition: '-200% 50%'
}
},
'text-blink': {
'0%, 100%': {
color: 'var(--primary)'
},
'50%': {
color: 'var(--muted-foreground)'
}
},
'bounce-dots': {
'0%, 100%': {
transform: 'scale(0.8)',
opacity: '0.5'
},
'50%': {
transform: 'scale(1.2)',
opacity: '1'
}
},
'thin-pulse': {
'0%, 100%': {
transform: 'scale(0.95)',
opacity: '0.8'
},
'50%': {
transform: 'scale(1.05)',
opacity: '0.4'
}
},
'pulse-dot': {
'0%, 100%': {
transform: 'scale(1)',
opacity: '0.8'
},
'50%': {
transform: 'scale(1.5)',
opacity: '1'
}
},
'shimmer-text': {
'0%': {
backgroundPosition: '150% center'
},
'100%': {
backgroundPosition: '-150% center'
}
},
'wave-bars': {
'0%, 100%': {
transform: 'scaleY(1)',
opacity: '0.5'
},
'50%': {
transform: 'scaleY(0.6)',
opacity: '1'
}
},
'spinner-fade': {
'0%': {
opacity: '0'
},
'100%': {
opacity: '1'
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Проверка расположения keyframes в конфиге:"
rg -n '^\s*keyframes\s*:' ui/tailwind.config.js -n -C2

echo
echo "2) Проверка использования дефолтных animate-* классов в UI:"
rg -nP --glob '**/*.{ts,tsx,js,jsx,html,css}' '\banimate-(spin|pulse|ping|bounce)\b' ui || true

echo
echo "Ожидание:"
echo "- если есть usage animate-spin/pulse/ping/bounce И keyframes задан в theme.keyframes,"
echo "  то перенос в theme.extend.keyframes обязателен."

Repository: lemone112/figmalint

Length of output: 875


🏁 Script executed:

head -n 110 ui/tailwind.config.js | tail -n 50

Repository: lemone112/figmalint

Length of output: 829


🏁 Script executed:

sed -n '1,110p' ui/tailwind.config.js | head -n 100

Repository: lemone112/figmalint

Length of output: 2758


🏁 Script executed:

tail -n 20 ui/tailwind.config.js

Repository: lemone112/figmalint

Length of output: 337


🏁 Script executed:

rg -n 'animation\s*:' ui/tailwind.config.js

Repository: lemone112/figmalint

Length of output: 45


🌐 Web query:

Tailwind CSS theme.keyframes vs theme.extend.keyframes override defaults

💡 Result:

Tailwind v3 (tailwind.config.js)

  • theme.keyframes (top-level) replaces the entire keyframes namespace from the default theme (same behavior as any other top-level theme key). If you do this, you’ll lose Tailwind’s built-in keyframes unless you re-add them yourself. [1]
  • theme.extend.keyframes merges your keyframes into the existing ones (keeps defaults, adds yours). [1]
  • If you define a keyframe with the same name under extend, that specific entry overrides the default entry (but doesn’t remove the rest). This follows from “extend merges” + normal object key replacement. [1]

Example:

// Replaces all default keyframes:
theme: { keyframes: { wiggle: { /*...*/ } } }

// Keeps defaults and adds wiggle:
theme: { extend: { keyframes: { wiggle: { /*...*/ } } } }

Tailwind v4 note (CSS-first theming)

Tailwind v4’s recommended approach is defining animations via @theme and --animate-*, with @keyframes inside @theme when you want them included with generated animation utilities. [2]

Sources:
[1] Tailwind CSS v3 docs — Theme Configuration (Extending vs Overriding; keyframes is a theme key)
[2] Tailwind CSS v4 docs — Theme variables (Defining animation keyframes)


🏁 Script executed:

rg -n 'animation' ui/tailwind.config.js -B2 -A2

Repository: lemone112/figmalint

Length of output: 45


🏁 Script executed:

wc -l ui/tailwind.config.js && sed -n '85,105p' ui/tailwind.config.js

Repository: lemone112/figmalint

Length of output: 485


Перенесите keyframes в theme.extend.keyframes: текущая позиция на уровне theme.keyframes полностью переопределяет стандартные keyframes Tailwind.

При текущей структуре конфига (строка 91: keyframes: { как сосед extend, а не внутри него) теряются встроенные keyframes: spin, pulse, ping, bounce. Несмотря на то, что в коде используются animate-spin и animate-pulse, эти утилиты останутся без соответствующих @keyframes и анимации не будут работать.

Переместите всё содержимое keyframes в theme.extend.keyframes, чтобы объединить пользовательские keyframes с дефолтными.

Исправление
   theme: {
   	extend: {
   		colors: {
        ...
   		},
   		fontSize: {
        ...
   		},
   		borderRadius: {
   			lg: 'var(--radius)',
   			md: 'calc(var(--radius) - 2px)',
   			sm: 'calc(var(--radius) - 4px)'
-  		}
-  	},
-  	keyframes: {
+  		},
+  		keyframes: {
   		typing: {
        ...
   		},
        ...
   		'spinner-fade': {
          ...
   		}
-  	}
+  	  }
+  	}
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/tailwind.config.js` around lines 91 - 198, The keyframes block is defined
at theme.keyframes which overrides Tailwind's defaults (removing spin, pulse,
ping, bounce); move the entire keyframes object into theme.extend.keyframes so
your custom keyframes merge with built-ins. Update the config by relocating the
current keyframes declaration under the existing extend object (reference:
theme.extend.keyframes) so utilities like animate-spin and animate-pulse (and
other default animations) continue to work alongside your custom animations.

Adds staggered mock messages (score card, markdown AI text, issues list,
user bubble, streaming indicator, action buttons) when running in dev mode.
Dynamic import ensures zero impact on production bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lemone112
lemone112 merged commit 4413d83 into main Mar 13, 2026
5 checks passed
@lemone112
lemone112 deleted the feat/sprint-8-tests-polish-ai-triggers branch March 27, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant