Skip to content

refactor(platform): improve ui#475

Merged
yannickmonney merged 3 commits into
mainfrom
feat/ui
Feb 15, 2026
Merged

refactor(platform): improve ui#475
yannickmonney merged 3 commits into
mainfrom
feat/ui

Conversation

@yannickmonney
Copy link
Copy Markdown
Contributor

@yannickmonney yannickmonney commented Feb 15, 2026

Summary by CodeRabbit

  • New Features

    • Added description support to form components (Checkbox, Input, Select, Radio, Switch, TextArea)
    • New FormSection component for organizing form fields
    • Refactored API Keys and Teams management interfaces with improved empty states
    • Enhanced page metadata and SEO support across dashboard routes
    • Improved browser language detection for better localization
  • Improvements

    • Updated table styling with refined hover states and header alignment
    • Shifted to cursor-based pagination for better performance
    • Enhanced form accessibility with description fields for user guidance
    • Refined loading states and skeletons across data tables

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Feb 15, 2026

Too many files changed for review. (138 files found, 100 file limit)

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 15, 2026

📝 Walkthrough

Walkthrough

This pull request introduces substantial UI and backend refactoring across the platform. It removes offset-based pagination infrastructure from Convex, replacing it with cursor-based and count-based approaches. Form components gain optional description props with accessibility support. Table configurations are updated with new column metadata (headerLabel) and header rendering changes. Routes receive SEO metadata via a new seo utility. Locale detection is enhanced with Accept-Language header parsing and resolution. Skeleton components are simplified by removing pagination support. Several table features are refactored to use component composition patterns instead of monolithic containers (e.g., ApiKeys, Teams settings pages).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 3
❌ Failed checks (2 warnings, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (114 files):

⚔️ services/crawler/pyproject.toml (content)
⚔️ services/crawler/uv.lock (content)
⚔️ services/graph-db/pyproject.toml (content)
⚔️ services/graph-db/uv.lock (content)
⚔️ services/platform/app/components/ui/data-display/table.tsx (content)
⚔️ services/platform/app/components/ui/data-table/column-builders.tsx (content)
⚔️ services/platform/app/components/ui/data-table/data-table-skeleton.tsx (content)
⚔️ services/platform/app/components/ui/data-table/data-table.tsx (content)
⚔️ services/platform/app/components/ui/filters/filters.stories.tsx (content)
⚔️ services/platform/app/components/ui/forms/checkbox.stories.tsx (content)
⚔️ services/platform/app/components/ui/forms/checkbox.tsx (content)
⚔️ services/platform/app/components/ui/forms/input.stories.tsx (content)
⚔️ services/platform/app/components/ui/forms/input.tsx (content)
⚔️ services/platform/app/components/ui/forms/json-input.tsx (content)
⚔️ services/platform/app/components/ui/forms/radio-group.stories.tsx (content)
⚔️ services/platform/app/components/ui/forms/radio-group.tsx (content)
⚔️ services/platform/app/components/ui/forms/select.stories.tsx (content)
⚔️ services/platform/app/components/ui/forms/select.tsx (content)
⚔️ services/platform/app/components/ui/forms/switch.stories.tsx (content)
⚔️ services/platform/app/components/ui/forms/switch.tsx (content)
⚔️ services/platform/app/components/ui/forms/textarea.stories.tsx (content)
⚔️ services/platform/app/components/ui/forms/textarea.tsx (content)
⚔️ services/platform/app/components/ui/overlays/popover.stories.tsx (content)
⚔️ services/platform/app/features/automations/components/automation-navigation.tsx (content)
⚔️ services/platform/app/features/automations/components/automations-table-skeleton.tsx (content)
⚔️ services/platform/app/features/automations/components/use-automations-table-config.tsx (content)
⚔️ services/platform/app/features/automations/executions/executions-client.tsx (content)
⚔️ services/platform/app/features/automations/executions/use-executions-table-config.tsx (content)
⚔️ services/platform/app/features/chat/components/human-input-request-card.tsx (content)
⚔️ services/platform/app/features/custom-agents/components/custom-agents-table-skeleton.tsx (content)
⚔️ services/platform/app/features/custom-agents/components/test-chat-panel.tsx (content)
⚔️ services/platform/app/features/custom-agents/components/tool-selector.tsx (content)
⚔️ services/platform/app/features/customers/components/customers-table-skeleton.tsx (content)
⚔️ services/platform/app/features/customers/hooks/use-customers-table-config.tsx (content)
⚔️ services/platform/app/features/documents/components/documents-client.tsx (content)
⚔️ services/platform/app/features/documents/components/onedrive-import-dialog.tsx (content)
⚔️ services/platform/app/features/products/components/product-table-skeleton.tsx (content)
⚔️ services/platform/app/features/products/hooks/use-products-table-config.tsx (content)
⚔️ services/platform/app/features/settings/audit-logs/components/audit-log-table.tsx (content)
⚔️ services/platform/app/features/settings/integrations/components/circuly-integration-dialog.tsx (content)
⚔️ services/platform/app/features/settings/integrations/components/shopify-integration-dialog.tsx (content)
⚔️ services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx (content)
⚔️ services/platform/app/features/tone-of-voice/components/example-messages-table.tsx (content)
⚔️ services/platform/app/features/vendors/components/vendors-table-skeleton.tsx (content)
⚔️ services/platform/app/features/vendors/hooks/use-vendors-table-config.tsx (content)
⚔️ services/platform/app/features/websites/components/websites-table-skeleton.tsx (content)
⚔️ services/platform/app/features/websites/hooks/use-websites-table-config.tsx (content)
⚔️ services/platform/app/hooks/__tests__/use-infinite-scroll.test.ts (content)
⚔️ services/platform/app/hooks/use-infinite-scroll.ts (content)
⚔️ services/platform/app/hooks/use-list-page.ts (content)
⚔️ services/platform/app/hooks/use-locale.ts (content)
⚔️ services/platform/app/routes/__root.tsx (content)
⚔️ services/platform/app/routes/_auth/log-in.tsx (content)
⚔️ services/platform/app/routes/_auth/sign-up.tsx (content)
⚔️ services/platform/app/routes/convex-dashboard.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/_knowledge.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/_knowledge/customers.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/_knowledge/documents.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/_knowledge/products.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/_knowledge/tone-of-voice.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/_knowledge/vendors.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/_knowledge/websites.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/approvals.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/automations.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/automations/$amId.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/automations/$amId/executions.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/automations/$amId/triggers.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/automations/index.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/chat.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/conversations.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/custom-agents.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/custom-agents/$agentId.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/custom-agents/$agentId/instructions.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/custom-agents/$agentId/knowledge.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/custom-agents/$agentId/webhook.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/custom-agents/index.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/settings.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/settings/account.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/settings/api-keys.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/settings/integrations.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/settings/logs.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/settings/organization.tsx (content)
⚔️ services/platform/app/routes/dashboard/$id/settings/teams.tsx (content)
⚔️ services/platform/app/routes/dashboard/create-organization.tsx (content)
⚔️ services/platform/app/routes/docs.tsx (content)
⚔️ services/platform/convex/custom_agents/queries.ts (content)
⚔️ services/platform/convex/customers/queries.ts (content)
⚔️ services/platform/convex/integrations/generate_oauth2_auth_url.ts (content)
⚔️ services/platform/convex/lib/pagination/helpers.ts (content)
⚔️ services/platform/convex/lib/pagination/index.ts (content)
⚔️ services/platform/convex/lib/pagination/types.ts (content)
⚔️ services/platform/convex/products/helpers.ts (content)
⚔️ services/platform/convex/products/queries.ts (content)
⚔️ services/platform/convex/products/types.ts (content)
⚔️ services/platform/convex/products/validators.ts (content)
⚔️ services/platform/convex/vendors/queries.ts (content)
⚔️ services/platform/convex/websites/queries.ts (content)
⚔️ services/platform/convex/wf_definitions/queries.ts (content)
⚔️ services/platform/convex/workflows/executions/helpers.ts (content)
⚔️ services/platform/convex/workflows/executions/types.ts (content)
⚔️ services/platform/convex/workflows/executions/validators.ts (content)
⚔️ services/platform/convex/workflows/validators.ts (content)
⚔️ services/platform/index.html (content)
⚔️ services/platform/lib/env.ts (content)
⚔️ services/platform/lib/pagination/types.ts (content)
⚔️ services/platform/lib/shared/schemas/pagination.ts (content)
⚔️ services/platform/lib/shared/schemas/products.ts (content)
⚔️ services/platform/lib/shared/schemas/wf_executions.ts (content)
⚔️ services/platform/messages/en.json (content)
⚔️ services/platform/server.js (content)
⚔️ services/platform/vite.config.ts (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
Title check ❓ Inconclusive The title "refactor(platform): improve ui" is vague and generic, using non-descriptive terms that don't convey the actual scope of changes in this large, multifaceted changeset. Provide a more specific and descriptive title that highlights the primary change, such as "refactor(platform): migrate to cursor-based pagination and add description fields to forms" or "refactor(platform): remove offset pagination and enhance form components with descriptions".
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/ui
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch feat/ui
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 14

Caution

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

⚠️ Outside diff range comments (9)
services/platform/lib/pagination/types.ts (1)

168-169: ⚠️ Potential issue | 🟡 Minor

Stale JSDoc: clearAll still references "reset pagination".

Pagination state has been removed from this module, but the comment on clearAll still says "Clear all filters and reset pagination." Update to match the new scope.

📝 Proposed fix
-  /** Clear all filters and reset pagination */
+  /** Clear all filters and sorting */
services/platform/convex/lib/pagination/types.ts (1)

15-58: 🧹 Nitpick | 🔵 Trivial

DEFAULT_PAGE_SIZE is exported twice — once via the wildcard on Line 15 and again explicitly on Line 58.

The export * on line 15 already re-exports DEFAULT_PAGE_SIZE from the shared schemas module. The explicit export { DEFAULT_PAGE_SIZE } on line 58 is redundant. Consider removing line 58 to avoid confusion.

♻️ Remove redundant export
-export { DEFAULT_PAGE_SIZE };
services/platform/convex/lib/pagination/helpers.ts (1)

44-47: ⚠️ Potential issue | 🟠 Major

Pre-existing: maxScanItems cutoff silently reports isDone: true even when more data may exist.

When the loop breaks at scanned >= maxScanItems (line 45), hasMore remains false, so the caller receives isDone: true and stops paginating — potentially missing rows beyond the scan window. Consider setting hasMore = true (or adding a truncated flag) when the scan limit is hit to avoid silent data loss for clients.

This is pre-existing behavior, not introduced by this PR, but worth addressing given the public API surface reduction that may increase reliance on this function.

🐛 Proposed fix
     if (scanned >= maxScanItems) {
+      hasMore = true;
       break;
     }
   }
+
+  // Also account for scan-limit hit before cursor was even found
+  // or while skipping filtered items

Note: the same scanned >= maxScanItems check on line 31 (inside the filter branch) also breaks without signalling that more data may exist. Both exit paths should set hasMore = true.

     if (filter && !filter(item)) {
       if (scanned >= maxScanItems) {
+        hasMore = true;
         break;
       }
       continue;
     }
services/platform/app/features/settings/integrations/components/shopify-integration-dialog.tsx (2)

60-67: 🧹 Nitpick | 🔵 Trivial

Duplicate toast descriptions for connect vs. update success.

Both branches of the ternary produce the identical description string (integrations.connectedTo). The title is correctly differentiated (updateSuccessful vs connectionSuccessful), but the description likely should differ too — or if the same text is intentional, collapse the ternary.

Proposed fix
       toast({
         title: isConnected
           ? t('integrations.updateSuccessful')
           : t('integrations.connectionSuccessful'),
-        description: isConnected
-          ? t('integrations.connectedTo', { provider: 'Shopify' })
-          : t('integrations.connectedTo', { provider: 'Shopify' }),
+        description: t('integrations.connectedTo', { provider: 'Shopify' }),
         variant: 'success',
       });

69-79: ⚠️ Potential issue | 🟡 Minor

Wrong i18n key in the connect/update error handler.

failedToDisconnect is used as the error description when a connect or update attempt fails. This will show a misleading message to the user (e.g., "Failed to disconnect from Shopify" when they were trying to connect).

The proposed key integrations.failedToConnect does not exist in the i18n files. Use an appropriate existing key instead, such as integrations.failedToTestConnection ("Failed to test connection"), or add the failedToConnect key to your i18n configuration.

Example fix using existing key
     } catch {
       // Keep dialog open and surface error without leaking credentials
       toast({
         title: isConnected
           ? t('integrations.updateFailed')
           : t('integrations.connectionTestFailed'),
-        description: t('integrations.failedToDisconnect', {
+        description: t('integrations.failedToTestConnection', {
           provider: 'Shopify',
         }),
         variant: 'destructive',
       });
services/platform/app/features/settings/integrations/components/circuly-integration-dialog.tsx (1)

126-137: ⚠️ Potential issue | 🟡 Minor

Wrong i18n key — failedToDisconnect used in the connect error handler.

Same issue as in the Shopify dialog: the description in the connect/update catch block uses integrations.failedToDisconnect, which is misleading when the operation that failed was a connect or update.

Proposed fix
     } catch (error) {
       console.error('Failed to connect to Circuly:', error);
       toast({
         title: isConnected
           ? t('integrations.updateFailed')
           : t('integrations.connectionTestFailed'),
-        description: t('integrations.failedToDisconnect', {
+        description: t('integrations.failedToConnect', {
           provider: 'Circuly',
         }),
         variant: 'destructive',
       });
services/platform/app/hooks/use-locale.ts (1)

28-28: ⚠️ Potential issue | 🟠 Major

Fix default locale to match config.ts.

useLocale defaults to 'en-US', but the canonical defaultLocale in services/platform/lib/i18n/config.ts is 'en'. Callers that rely on the hook's default parameter will get inconsistent locale resolution. Import and use the config constant instead.

Suggested fix
+import { defaultLocale } from '@/lib/i18n/config';
 import { loadDayjsLocale } from '@/lib/utils/date/format';
 import { isValidLocale } from '@/lib/utils/intl/is-valid-locale';
 import { parseAcceptLanguage } from '@/lib/utils/intl/parse-accept-language';
 import { resolveLocale } from '@/lib/utils/intl/resolve-locale';

-export function useLocale(defaultLocale = 'en-US') {
+export function useLocale(fallbackLocale = defaultLocale) {
services/platform/app/hooks/use-list-page.ts (1)

214-227: 🧹 Nitpick | 🔵 Trivial

Prefetch logic looks solid.

The approach of checking remainingAfterIncrement <= pageSize before the buffer is exhausted, combined with the 3× batch size, should significantly reduce perceived latency during scrolling.

One observation: handleLoadMore depends on dataSource (the full object), which likely has a new identity on every render from the paginated query hook. This means the callback is recreated frequently. Consider destructuring the specific fields (dataSource.status, dataSource.loadMore) into stable references if you observe unnecessary IntersectionObserver teardown/setup cycles downstream.

services/platform/app/components/ui/data-table/column-builders.tsx (1)

258-264: 🧹 Nitpick | 🔵 Trivial

Pre-existing: truncate option is silently ignored when className is provided.

When options?.className is set, the nullish coalescing operator short-circuits, and options?.truncate has no effect. This is pre-existing behavior, but worth noting if truncate is expected to work independently of className.

♻️ Possible improvement
     cell: ({ row }) => {
       const value = row.original[accessorKey];
       const text = value ? String(value) : (options?.emptyText ?? '-');
       return (
         <span
-          className={
-            options?.className ??
-            `text-muted-foreground text-xs ${options?.truncate ? 'block max-w-sm truncate' : ''}`
-          }
+          className={cn(
+            options?.className ?? 'text-muted-foreground text-xs',
+            options?.truncate && 'block max-w-sm truncate',
+          )}
         >
           {text}
         </span>
       );
     },
🤖 Fix all issues with AI agents
In `@services/platform/app/components/ui/forms/form-section.tsx`:
- Around line 22-37: The container in FormSection should be exposed as an
accessible group: import React's useId and generate an id (e.g., const id =
useId()) then set the root div (the one using cn('flex flex-col gap-3',
className)) to role="group" and conditionally add aria-labelledby={label ?
`${id}-label` : undefined} and aria-describedby={description ? `${id}-desc` :
undefined}; update the label span to have id={`${id}-label`} and the Description
component to have id={`${id}-desc`} so screen readers associate the
label/description with the children while preserving existing props (children,
className).

In `@services/platform/app/components/ui/forms/json-input.tsx`:
- Around line 391-393: The Description element is rendered without an id and not
linked to the interactive control, so create a descriptionId (derived from the
component id prop or generated) and pass it as the id prop to <Description>,
then add aria-describedby="{descriptionId}" to the interactive controls (the
<Textarea> used in this component and the JSON viewer container element) so
screen readers associate the description with the input; also update the prop
type for description from string to ReactNode to match other form components.

In `@services/platform/app/components/ui/forms/switch.tsx`:
- Around line 62-91: The two render paths render the switch on different sides;
make them consistent by changing the label-only branch to mirror the description
branch layout: render a container (e.g., <div className="flex items-center
justify-between">) with the Label (htmlFor={id}, required={required}) on the
left and the switchElement on the right, keeping the same className/cursor
behavior as the description branch and preserving accessibility IDs (id and
descriptionId) and props used in the switchElement so the visual position
doesn't flip when description is added or removed.

In
`@services/platform/app/features/automations/components/automation-navigation.tsx`:
- Around line 224-229: The loading skeleton is hidden on mobile because of
"hidden md:flex"; add a mobile-visible placeholder so users on small screens get
feedback: keep the existing desktop skeleton (the block using isLoading and
Skeleton) and add a separate element visible only on mobile (e.g., a div with
className "flex md:hidden" containing appropriately sized Skeletons) so the
action area shows a compact skeleton while isLoading is true; ensure the mobile
dropdown logic that currently checks !isLoading remains unchanged so the
dropdown still waits for load, but replace the hidden skeleton with a mobile
skeleton tied to the same isLoading condition (referencing isLoading and the
Skeleton components in automation-navigation.tsx).

In
`@services/platform/app/features/settings/integrations/components/circuly-integration-dialog.tsx`:
- Line 223: Replace the misuse of FormSection (which renders
label+description+children) with the simpler Description component: import
Description alongside other form atoms, change the JSX where FormSection is used
with only a description prop to instead render
<Description>{t('integrations.circuly.syncingData')}</Description>, and remove
the now-unused FormSection import if it’s no longer referenced (check the
cir​culy-integration-dialog.tsx component for other FormSection usages before
deleting).

In `@services/platform/app/routes/__root.tsx`:
- Around line 22-30: The head() meta property is using an unnecessary spread of
the seo() result; update the head implementation to assign meta: seo({...})
directly (replace the current meta: () => ({ meta: [...seo({ title: 'Tale',
description: '...'} )] }) pattern) so it matches other routes and avoids
creating a redundant array copy; modify the head function that references
seo(...) accordingly.

In `@services/platform/app/routes/dashboard/`$id/_knowledge/websites.tsx:
- Around line 50-61: The code in this route inlines the loading/filter checks
while other routes extract hasServerFilters and isInitialLoading for clarity;
refactor the conditional by creating named boolean variables (e.g.,
hasServerFilters = Boolean(search.status) or however other routes derive it, and
isInitialLoading = paginatedResult.status === 'LoadingFirstPage' &&
!search.status) and then use those variables in the early returns for
WebsitesEmptyState and WebsitesTableSkeleton (referencing paginatedResult,
search, count, organizationId, WebsitesEmptyState, and WebsitesTableSkeleton) so
the logic matches the customers/vendors/products pages for readability and
consistency.

In `@services/platform/app/routes/dashboard/`$id/automations/$amId.tsx:
- Around line 58-63: The head() currently calls seo() with hardcoded English
strings (title/description) which can't use i18n hooks; instead, make SEO
locale-aware by computing localized meta in the route loader (or route context)
and returning it as part of loaderData, then change head() to read those
localized values from loaderData (or route context) and pass them into seo() —
update the loader that corresponds to this route to expose the locale and
localized title/description and update head() to use those loaderData fields
rather than hardcoded strings (refer to head(), seo(), and the route loader).

In `@services/platform/app/routes/dashboard/`$id/settings/teams.tsx:
- Around line 40-51: TeamsContent currently passes organizationId but calls
useTeams() without it; change useTeams to accept an organizationId parameter
(mirror useApiKeys pattern) and update its implementation to use the passed
organizationId instead of calling useOrganizationId() internally, then update
TeamsContent to call useTeams(organizationId); also update any other call sites
of useTeams (or make the param optional for backwards compatibility) and ensure
components like TeamsTableSkeleton, TeamsEmptyState, and TeamsTable still
receive organizationId as before.

In `@services/platform/lib/utils/intl/parse-accept-language.test.ts`:
- Around line 54-57: The test name claims negatives are clamped but the regex in
parseAcceptLanguage (currently /^q\s*=\s*(\d+(?:\.\d+)?)$/) doesn't match a
leading '-' so "-0.1" is treated as unparseable and falls back to default 1;
either update parseAcceptLanguage to accept an optional leading sign and then
clamp the parsed quality to the 0–1 range (modify the quality parser inside
parseAcceptLanguage to use a regex like allowing an optional '-' and then coerce
via Math.max(0, Math.min(1, parsed))) or change the test description to reflect
that unparseable qualities default to 1; reference parseAcceptLanguage and the
regex when making the change.

In `@services/platform/lib/utils/intl/resolve-locale.test.ts`:
- Around line 24-26: The test name for the case using resolveLocale is
misleading: it claims to "fall back to en-US" but the assertion expects 'en'
because resolveLocale first checks isValidLocale('en') and returns it directly;
update the test to either (a) change the description to reflect that
resolveLocale returns 'en' for a bare 'en' input (e.g., "returns 'en' for bare
'en' when valid"), or (b) if you intend to assert the en-US fallback, adjust the
test setup so isValidLocale('en') is false (or remove 'en' from input) and then
assert 'en-US'; reference resolveLocale and isValidLocale when making the
change.

In `@services/platform/lib/utils/seo.ts`:
- Around line 1-21: The seo utility currently hardcodes og:type and omits
Twitter cards; update the SeoParams type and seo function to accept an optional
ogType (e.g., ogType?: string) and use it when pushing the { name: 'og:type',
content: ... } tag (fall back to 'website' if not provided), and add optional
Twitter meta tags (e.g., { name: 'twitter:card', content: 'summary_large_image'
}, { name: 'twitter:title', content: title }, and if description present { name:
'twitter:description', content: description }) into the tags array so both Open
Graph type and Twitter card metadata are supported.

In `@services/platform/server.js`:
- Around line 41-51: The injected Accept-Language (and envConfig) JSON is
vulnerable to XSS because JSON.stringify doesn't escape sequences like
"</script>" or "<!--"; update the replacement logic around indexHtmlTemplate so
after JSON.stringify(acceptLanguage) and JSON.stringify(envConfig) you run a
sanitizer that escapes dangerous HTML/script-breaking sequences (e.g. replace
"</" with "<\/" and "<!--" with "<\\!--" or similar) before inserting into the
window.__ACCEPT_LANGUAGE__ and window.__ENV__ replacements; keep the
sanitization helper local (e.g., escapeForScript) and use it when constructing
the two replacement strings to ensure the script context cannot be broken by
user-controlled headers.

In `@services/platform/vite-plugins/inject-accept-language.ts`:
- Around line 28-31: The injected Accept-Language value is
JSON.stringify(acceptLanguage) which does not escape the sequence "</script>",
allowing a dev-only self-XSS; to harden it, create a safe string from
JSON.stringify(acceptLanguage) that escapes any "</script>" sequences (e.g.,
replace "</script>" with "<\/script>" in the stringified value) and use that
safe string in the chunk.replace call (the replacement around injected /
chunk.replace and acceptLanguage).

Comment on lines +22 to +37
return (
<div className={cn('flex flex-col gap-3', className)}>
{(label || description) && (
<div className="flex flex-col gap-1">
{label && (
<span className="text-muted-foreground text-xs font-medium md:text-sm">
{label}
</span>
)}
{description && (
<Description className="text-xs">{description}</Description>
)}
</div>
)}
{children}
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding role="group" with aria-labelledby for accessible grouping.

Since FormSection is meant to label a group of controls, screen readers won't associate the label/description with the children unless the container is marked as a group. A <fieldset>/<legend> or role="group" with aria-labelledby would improve the experience.

♿ Proposed accessibility improvement
 export function FormSection({
   label,
   description,
   children,
   className,
 }: FormSectionProps) {
+  const id = React.useId();
+  const labelId = `${id}-label`;
+  const descId = `${id}-desc`;
+
   return (
-    <div className={cn('flex flex-col gap-3', className)}>
+    <div
+      role="group"
+      aria-labelledby={label ? labelId : undefined}
+      aria-describedby={description ? descId : undefined}
+      className={cn('flex flex-col gap-3', className)}
+    >
       {(label || description) && (
         <div className="flex flex-col gap-1">
           {label && (
-            <span className="text-muted-foreground text-xs font-medium md:text-sm">
+            <span id={labelId} className="text-muted-foreground text-xs font-medium md:text-sm">
               {label}
             </span>
           )}
           {description && (
-            <Description className="text-xs">{description}</Description>
+            <Description id={descId} className="text-xs">{description}</Description>
           )}
         </div>
       )}
       {children}
     </div>
   );
 }

This would require adding import React from 'react' (or import { useId } from 'react').

🤖 Prompt for AI Agents
In `@services/platform/app/components/ui/forms/form-section.tsx` around lines 22 -
37, The container in FormSection should be exposed as an accessible group:
import React's useId and generate an id (e.g., const id = useId()) then set the
root div (the one using cn('flex flex-col gap-3', className)) to role="group"
and conditionally add aria-labelledby={label ? `${id}-label` : undefined} and
aria-describedby={description ? `${id}-desc` : undefined}; update the label span
to have id={`${id}-label`} and the Description component to have
id={`${id}-desc`} so screen readers associate the label/description with the
children while preserving existing props (children, className).

Comment on lines 391 to 393
{description && (
<p className="text-muted-foreground text-xs">{description}</p>
<Description className="text-xs">{description}</Description>
)}
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

Missing aria-describedby linkage for the description.

Other form components in this PR (Checkbox, Input, Textarea) generate a descriptionId, pass it as id to the <Description> element, and wire aria-describedby on the interactive control. This component renders the Description without an id and without linking it to the <Textarea> or JSON viewer, so screen readers won't associate the description with the input.

Additionally, description is typed as string here (line 40) while the other form components type it as ReactNode — consider aligning for consistency.

🛡️ Proposed fix

Generate a descriptionId (e.g., alongside the existing id prop) and wire it through:

 export function JsonInput({
   ...
   id,
 }: JsonInputProps) {
+  const descriptionId = id ? `${id}-description` : undefined;
   ...

Then on the <Textarea> (line 300–323) and the JSON viewer container, add aria-describedby:

          <Textarea
            ref={textareaRef}
+           aria-describedby={description && descriptionId ? descriptionId : undefined}
            ...
          />

And pass the id to the Description:

-        <Description className="text-xs">{description}</Description>
+        <Description id={descriptionId} className="text-xs">{description}</Description>
🤖 Prompt for AI Agents
In `@services/platform/app/components/ui/forms/json-input.tsx` around lines 391 -
393, The Description element is rendered without an id and not linked to the
interactive control, so create a descriptionId (derived from the component id
prop or generated) and pass it as the id prop to <Description>, then add
aria-describedby="{descriptionId}" to the interactive controls (the <Textarea>
used in this component and the JSON viewer container element) so screen readers
associate the description with the input; also update the prop type for
description from string to ReactNode to match other form components.

Comment on lines +62 to +91
if (description) {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
{label && (
<Label
htmlFor={id}
required={required}
className="cursor-pointer"
>
{label}
</Label>
)}
{switchElement}
</div>
<Description id={descriptionId} className="text-xs">
{description}
</Description>
</div>
);
}

return (
<div className="flex items-center gap-2">
{switchElement}
<Label htmlFor={id} required={required} className="cursor-pointer">
{label}
</Label>
</div>
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Inconsistent switch position relative to label across rendering paths.

When description is present (line 62 branch), the layout is justify-between placing the label on the left and switch on the right. When only label is present (line 84 branch), the switch is rendered before the label (left side). This means adding or removing a description prop flips the switch's visual position, which could be jarring in forms that mix both variants.

Consider aligning the two layouts so the switch is consistently on the same side of the label:

Proposed fix — label-only layout matches the description layout
     return (
-      <div className="flex items-center gap-2">
-        {switchElement}
-        <Label htmlFor={id} required={required} className="cursor-pointer">
+      <div className="flex items-center justify-between">
+        <Label htmlFor={id} required={required} className="cursor-pointer">
           {label}
         </Label>
+        {switchElement}
       </div>
     );
🤖 Prompt for AI Agents
In `@services/platform/app/components/ui/forms/switch.tsx` around lines 62 - 91,
The two render paths render the switch on different sides; make them consistent
by changing the label-only branch to mirror the description branch layout:
render a container (e.g., <div className="flex items-center justify-between">)
with the Label (htmlFor={id}, required={required}) on the left and the
switchElement on the right, keeping the same className/cursor behavior as the
description branch and preserving accessibility IDs (id and descriptionId) and
props used in the switchElement so the visual position doesn't flip when
description is added or removed.

Comment on lines +224 to +229
{isLoading && (
<div className="hidden items-center gap-2 md:flex">
<Skeleton className="h-8 w-12 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
</div>
)}
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

Loading skeleton is hidden on mobile.

The skeleton placeholder (Lines 225-228) uses hidden md:flex, so mobile users see no loading indicator in the actions area while isLoading is true. Meanwhile, the mobile dropdown (Line 303) is also gated by !isLoading. Consider adding a mobile-visible skeleton or at minimum a small placeholder so mobile users get visual feedback.

🤖 Prompt for AI Agents
In
`@services/platform/app/features/automations/components/automation-navigation.tsx`
around lines 224 - 229, The loading skeleton is hidden on mobile because of
"hidden md:flex"; add a mobile-visible placeholder so users on small screens get
feedback: keep the existing desktop skeleton (the block using isLoading and
Skeleton) and add a separate element visible only on mobile (e.g., a div with
className "flex md:hidden" containing appropriately sized Skeletons) so the
action area shows a compact skeleton while isLoading is true; ensure the mobile
dropdown logic that currently checks !isLoading remains unchanged so the
dropdown still waits for load, but replace the hidden skeleton with a mobile
skeleton tied to the same isLoading condition (referencing isLoading and the
Skeleton components in automation-navigation.tsx).

<Description className="text-xs">
{t('integrations.circuly.syncingData')}
</Description>
<FormSection description={t('integrations.circuly.syncingData')} />
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

FormSection used as a standalone description wrapper (no label or children).

FormSection is a container that renders a label, description header, and then {children}. Using it with only description and no children or label works but feels like a misuse of the component's intent. A plain <Description> would be more semantically appropriate here.

Proposed fix

Import Description directly:

-import { FormSection } from '@/app/components/ui/forms/form-section';
+import { Description } from '@/app/components/ui/forms/description';

Then at line 223:

-          <FormSection description={t('integrations.circuly.syncingData')} />
+          <Description className="text-xs">
+            {t('integrations.circuly.syncingData')}
+          </Description>
🤖 Prompt for AI Agents
In
`@services/platform/app/features/settings/integrations/components/circuly-integration-dialog.tsx`
at line 223, Replace the misuse of FormSection (which renders
label+description+children) with the simpler Description component: import
Description alongside other form atoms, change the JSX where FormSection is used
with only a description prop to instead render
<Description>{t('integrations.circuly.syncingData')}</Description>, and remove
the now-unused FormSection import if it’s no longer referenced (check the
cir​culy-integration-dialog.tsx component for other FormSection usages before
deleting).

Comment on lines +54 to +57
it('clamps quality to 0–1 range', () => {
const result = parseAcceptLanguage('en;q=1.5, de;q=-0.1');
expect(result).toEqual(['en', 'de']);
});
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

Negative quality value isn't actually clamped — it's silently ignored by the regex.

The regex in parseAcceptLanguage (/^q\s*=\s*(\d+(?:\.\d+)?)$/) doesn't match negative values like -0.1, so de;q=-0.1 falls through to the default quality of 1 rather than being clamped to 0. The test still passes, but it's testing "unparseable quality defaults to 1" rather than "negative quality is clamped to 0". Consider adding a comment to clarify the actual behavior, or adjusting the test name.

🤖 Prompt for AI Agents
In `@services/platform/lib/utils/intl/parse-accept-language.test.ts` around lines
54 - 57, The test name claims negatives are clamped but the regex in
parseAcceptLanguage (currently /^q\s*=\s*(\d+(?:\.\d+)?)$/) doesn't match a
leading '-' so "-0.1" is treated as unparseable and falls back to default 1;
either update parseAcceptLanguage to accept an optional leading sign and then
clamp the parsed quality to the 0–1 range (modify the quality parser inside
parseAcceptLanguage to use a regex like allowing an optional '-' and then coerce
via Math.max(0, Math.min(1, parsed))) or change the test description to reflect
that unparseable qualities default to 1; reference parseAcceptLanguage and the
regex when making the change.

Comment on lines +24 to +26
it('falls back to en-US for bare "en" via en fallback', () => {
expect(resolveLocale(['en'], 'de-DE')).toBe('en');
});
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

Misleading test description — the assertion contradicts the name.

The test is named 'falls back to en-US for bare "en" via en fallback' but asserts the result is 'en', not 'en-US'. If isValidLocale('en') returns true, the function returns 'en' directly on the first check (line 16 of resolve-locale.ts) and never reaches the en-US fallback branch. Either the description should be updated to reflect what's actually being tested, or the assertion is wrong.

Suggested description fix
-  it('falls back to en-US for bare "en" via en fallback', () => {
+  it('resolves bare "en" directly when it is a valid locale', () => {
     expect(resolveLocale(['en'], 'de-DE')).toBe('en');
   });
🤖 Prompt for AI Agents
In `@services/platform/lib/utils/intl/resolve-locale.test.ts` around lines 24 -
26, The test name for the case using resolveLocale is misleading: it claims to
"fall back to en-US" but the assertion expects 'en' because resolveLocale first
checks isValidLocale('en') and returns it directly; update the test to either
(a) change the description to reflect that resolveLocale returns 'en' for a bare
'en' input (e.g., "returns 'en' for bare 'en' when valid"), or (b) if you intend
to assert the en-US fallback, adjust the test setup so isValidLocale('en') is
false (or remove 'en' from input) and then assert 'en-US'; reference
resolveLocale and isValidLocale when making the change.

Comment thread services/platform/lib/utils/seo.ts Outdated
Comment on lines +1 to +21
interface SeoParams {
title: string;
description?: string;
}

export function seo({ title, description }: SeoParams) {
const tags: Array<Record<string, string>> = [
{ title },
{ name: 'og:title', content: title },
{ name: 'og:type', content: 'website' },
];

if (description) {
tags.push(
{ name: 'description', content: description },
{ name: 'og:description', content: description },
);
}

return tags;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Clean utility — consider a few SEO enhancements for completeness.

The implementation is correct and works well with TanStack Router's head API. A couple of optional improvements:

  1. og:type is hardcoded to 'website' — fine for most pages, but you may want to make it configurable if you later have blog/article pages.
  2. Consider adding twitter:card / twitter:title / twitter:description meta tags for better social sharing coverage.
🤖 Prompt for AI Agents
In `@services/platform/lib/utils/seo.ts` around lines 1 - 21, The seo utility
currently hardcodes og:type and omits Twitter cards; update the SeoParams type
and seo function to accept an optional ogType (e.g., ogType?: string) and use it
when pushing the { name: 'og:type', content: ... } tag (fall back to 'website'
if not provided), and add optional Twitter meta tags (e.g., { name:
'twitter:card', content: 'summary_large_image' }, { name: 'twitter:title',
content: title }, and if description present { name: 'twitter:description',
content: description }) into the tags array so both Open Graph type and Twitter
card metadata are supported.

Comment on lines +41 to +51
const acceptLanguage = req.headers['accept-language'] || '';

const html = indexHtmlTemplate
.replace(
/window\.__ENV__\s*=\s*['"]__ENV_PLACEHOLDER__['"];/,
`window.__ENV__ = ${JSON.stringify(envConfig)};`,
)
.replace(
/window\.__ACCEPT_LANGUAGE__\s*=\s*['"]__ACCEPT_LANGUAGE_PLACEHOLDER__['"];/,
`window.__ACCEPT_LANGUAGE__ = ${JSON.stringify(acceptLanguage)};`,
);
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

XSS risk: JSON.stringify does not escape </script> sequences.

An attacker can send a crafted Accept-Language header containing </script><script>alert(1)</script>. JSON.stringify escapes quotes and backslashes but does not escape </ or <!--, so the injected value can break out of the <script> tag context.

The same risk applies to envConfig on line 46, though that is derived from server-controlled env vars, making exploitation less likely. The Accept-Language header is fully client-controlled.

🔒 Proposed fix: sanitize JSON output for HTML script context

Add a helper that escapes dangerous sequences after JSON.stringify:

+function htmlSafeJsonStringify(value) {
+  return JSON.stringify(value)
+    .replace(/</g, '\\u003c')
+    .replace(/>/g, '\\u003e')
+    .replace(/&/g, '\\u0026')
+    .replace(/'/g, '\\u0027');
+}
+
 app.get('{*path}', (req, res) => {
   // ...
   const html = indexHtmlTemplate
     .replace(
       /window\.__ENV__\s*=\s*['"]__ENV_PLACEHOLDER__['"];/,
-      `window.__ENV__ = ${JSON.stringify(envConfig)};`,
+      `window.__ENV__ = ${htmlSafeJsonStringify(envConfig)};`,
     )
     .replace(
       /window\.__ACCEPT_LANGUAGE__\s*=\s*['"]__ACCEPT_LANGUAGE_PLACEHOLDER__['"];/,
-      `window.__ACCEPT_LANGUAGE__ = ${JSON.stringify(acceptLanguage)};`,
+      `window.__ACCEPT_LANGUAGE__ = ${htmlSafeJsonStringify(acceptLanguage)};`,
     );
🤖 Prompt for AI Agents
In `@services/platform/server.js` around lines 41 - 51, The injected
Accept-Language (and envConfig) JSON is vulnerable to XSS because JSON.stringify
doesn't escape sequences like "</script>" or "<!--"; update the replacement
logic around indexHtmlTemplate so after JSON.stringify(acceptLanguage) and
JSON.stringify(envConfig) you run a sanitizer that escapes dangerous
HTML/script-breaking sequences (e.g. replace "</" with "<\/" and "<!--" with
"<\\!--" or similar) before inserting into the window.__ACCEPT_LANGUAGE__ and
window.__ENV__ replacements; keep the sanitization helper local (e.g.,
escapeForScript) and use it when constructing the two replacement strings to
ensure the script context cannot be broken by user-controlled headers.

Comment on lines +28 to +31
const injected = chunk.replace(
"'__ACCEPT_LANGUAGE_PLACEHOLDER__'",
JSON.stringify(acceptLanguage),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Minor XSS vector via </script> in Accept-Language header (dev-only, self-XSS).

JSON.stringify doesn't escape forward slashes, so a crafted Accept-Language value containing </script> would break out of the inline <script> tag in the HTML. Since this is dev-only and the header originates from the developer's own browser, the risk is negligible (self-XSS). However, if you want defense-in-depth, you can escape it:

Optional hardening
             const injected = chunk.replace(
               "'__ACCEPT_LANGUAGE_PLACEHOLDER__'",
-              JSON.stringify(acceptLanguage),
+              JSON.stringify(acceptLanguage).replace(/</g, '\\u003c'),
             );
🤖 Prompt for AI Agents
In `@services/platform/vite-plugins/inject-accept-language.ts` around lines 28 -
31, The injected Accept-Language value is JSON.stringify(acceptLanguage) which
does not escape the sequence "</script>", allowing a dev-only self-XSS; to
harden it, create a safe string from JSON.stringify(acceptLanguage) that escapes
any "</script>" sequences (e.g., replace "</script>" with "<\/script>" in the
stringified value) and use that safe string in the chunk.replace call (the
replacement around injected / chunk.replace and acceptLanguage).

@yannickmonney yannickmonney merged commit 1569e2e into main Feb 15, 2026
16 checks passed
@yannickmonney yannickmonney deleted the feat/ui branch February 15, 2026 17:54
yannickmonney added a commit that referenced this pull request Apr 8, 2026
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