diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 8c8b468d246..0342cadb8cb 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -24,10 +24,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`Chip` / `ChipLink`** — the pill button (`` / Next ``). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`. - **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner ``); `className` styles the chrome wrapper. - **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit. -- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`). +- **`ChipTextarea`** — multi-line sibling. `monospace` for structured text, `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`). - **`ChipSelect`** — canonical menu selection. Single selection uses `value`/`onChange`; `multiSelect` uses `multiSelectValues`/`onMultiSelectChange`, checkbox rows, and stays open. Supports groups, search aliases, rich labels/icons, disabled options, optional single-selection checks, and trigger refs. Rich labels require a nonempty `searchTerms` array containing their visible text, followed by any aliases; plain string/number labels are searchable automatically. `showAllOption` clears to an empty selection; without it, use `placeholder` for the empty state. Owns its chevron. `dropdownWidth` accepts `'trigger'`, `'content'`, or pixels; `modal={false}` keeps outside-dialog filters non-modal, while nested dialogs retain their focus lock. - **`ChipCombobox`** — shared chip styling over the separate `Combobox` engine for editable values, asynchronous results, and virtualized lists. Plain menu selection, including searchable menus, uses `ChipSelect`. -- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. +- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. - **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array. - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. diff --git a/.cursor/rules/emcn-components.mdc b/.cursor/rules/emcn-components.mdc index 75fe49a83e3..9b19e89c154 100644 --- a/.cursor/rules/emcn-components.mdc +++ b/.cursor/rules/emcn-components.mdc @@ -25,10 +25,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`Chip` / `ChipLink`** — the pill button (`` / Next ``). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`. - **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner ``); `className` styles the chrome wrapper. - **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit. -- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`). +- **`ChipTextarea`** — multi-line sibling. `monospace` for structured text, `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`). - **`ChipSelect`** — canonical menu selection. Single selection uses `value`/`onChange`; `multiSelect` uses `multiSelectValues`/`onMultiSelectChange`, checkbox rows, and stays open. Supports groups, search aliases, rich labels/icons, disabled options, optional single-selection checks, and trigger refs. Rich labels require a nonempty `searchTerms` array containing their visible text, followed by any aliases; plain string/number labels are searchable automatically. `showAllOption` clears to an empty selection; without it, use `placeholder` for the empty state. Owns its chevron. `dropdownWidth` accepts `'trigger'`, `'content'`, or pixels; `modal={false}` keeps outside-dialog filters non-modal, while nested dialogs retain their focus lock. - **`ChipCombobox`** — shared chip styling over the separate `Combobox` engine for editable values, asynchronous results, and virtualized lists. Plain menu selection, including searchable menus, uses `ChipSelect`. -- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. +- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. - **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array. - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index bb09919dc63..d032a28cf95 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -1020,7 +1020,8 @@ export default function ResumeExecutionPage({ placeholder='{"example": "value"}' rows={6} spellCheck={false} - className='min-h-[180px] font-mono' + monospace + className='min-h-[180px]' /> diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx index d01953901f4..959e9a3e6aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx @@ -275,7 +275,8 @@ function ClientCredentialAccountModalForm({ value={value} onChange={(event) => setField(field.id, event.target.value)} placeholder={field.placeholder} - className='min-h-[120px] font-mono' + monospace + className='min-h-[120px]' // Browser spell-check and autofill ship textarea contents to // third-party services — an exfiltration route for a pasted // private key. `ChipModalField type='textarea'` exposes none diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index d1d3ceb4b4c..155a417f5bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -763,7 +763,8 @@ export function McpServerFormModal({ type='button' variant='ghost' onClick={() => setShowAdvanced((v) => !v)} - className='gap-1 self-start py-0 text-small' + size='inline' + className='gap-1 self-start' > {showAdvanced ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index 82c1009e63d..9e00fc9c38a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -83,18 +83,16 @@ export function NoOrganizationView({ Team URL - - - sim.ai/team/ - - setOrgSlug(e.target.value)} - placeholder='my-team' - className='rounded-l-none' - /> - + setOrgSlug(e.target.value)} + placeholder='my-team' + className='mt-1' + startAdornment={ + sim.ai/team/ + } + /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx index e72cdce34ad..694361bc085 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx @@ -95,7 +95,7 @@ export function LockSettingsModal({ {field.label} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx new file mode 100644 index 00000000000..039844dfa4d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx @@ -0,0 +1,86 @@ +import type { ComponentProps, PropsWithChildren } from 'react' +import { JSDOM } from 'jsdom' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +const validation = vi.hoisted(() => ({ + current: { isChecking: false, error: 'Use lowercase letters', isValid: false } as { + isChecking: boolean + error: string | null + isValid: boolean + }, +})) + +vi.mock('@sim/emcn', () => ({ + Input: (props: ComponentProps<'input'>) => , + Label: (props: ComponentProps<'label'>) => ( + + {props.children} + + ), + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + Tooltip: { + Root: ({ children }: PropsWithChildren) => <>{children}>, + Trigger: ({ children }: PropsWithChildren) => <>{children}>, + Content: ({ children }: PropsWithChildren) => <>{children}>, + }, +})) +vi.mock('@sim/emcn/icons', () => ({ Check: () => null, TriangleAlert: () => null })) +vi.mock('@sim/logger', () => ({ createLogger: () => ({}) })) +vi.mock('@/components/ui', () => ({ GeneratedPasswordInput: () => null })) +vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({}) })) +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.ai', + getEmailDomain: () => 'sim.ai', +})) +vi.mock('@/lib/messaging/email/validation', () => ({ validateAllowlistEntry: () => true })) +vi.mock('@/lib/workflows/streaming/output-selector', () => ({ + formatInternalOutputSelector: () => '', +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select', + () => ({ + OutputSelect: () => null, + }) +) +vi.mock('@/hooks/queries/chats', () => ({ + useCreateChat: () => ({}), + useDeleteChat: () => ({}), + useRevealChatPassword: () => ({}), + useUpdateChat: () => ({}), +})) +vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({}) })) +vi.mock('./hooks', () => ({ useIdentifierValidation: () => validation.current })) + +import { IdentifierInput } from './chat' + +function renderIdentifier() { + return new JSDOM(renderToStaticMarkup()) + .window.document +} + +describe('deploy URL field error', () => { + it('announces and associates the URL validation error with its input', () => { + validation.current = { isChecking: false, error: 'Use lowercase letters', isValid: false } + const document = renderIdentifier() + const input = document.querySelector('#chat-url') + const alert = document.querySelector('[role="alert"]') + + expect(alert?.textContent).toBe('Use lowercase letters') + expect(alert?.className).toBe('mt-[6.5px] text-[var(--text-error)] text-caption') + expect(input?.getAttribute('aria-invalid')).toBe('true') + expect(input?.getAttribute('aria-describedby')).toBe(alert?.id) + expect(alert?.id).toBeTruthy() + expect(document.querySelector('label')?.htmlFor).toBe(input?.id) + }) + + it('omits the error relationship when the URL is valid', () => { + validation.current = { isChecking: false, error: null, isValid: true } + const document = renderIdentifier() + const input = document.querySelector('#chat-url') + + expect(document.querySelector('[role="alert"]')).toBeNull() + expect(input?.getAttribute('aria-invalid')).toBe('false') + expect(input?.hasAttribute('aria-describedby')).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index b9dfb3271a2..3caefaad692 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useId, useRef, useState } from 'react' import { ChipButtonGroup, ChipButtonGroupItem, @@ -49,6 +49,19 @@ const logger = createLogger('ChatDeploy') const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/ +interface DeployFieldErrorProps { + children: ReactNode + id?: string +} + +function DeployFieldError({ children, id }: DeployFieldErrorProps) { + return ( + + {children} + + ) +} + interface ChatDeployProps { workflowId: string deploymentInfo: { @@ -382,11 +395,7 @@ export function ChatDeploy({ className='w-full' disablePortal /> - {errors.outputBlocks && ( - - {errors.outputBlocks} - - )} + {errors.outputBlocks && {errors.outputBlocks}} @@ -539,7 +548,7 @@ const getDomainPrefix = (() => { return () => prefix })() -function IdentifierInput({ +export function IdentifierInput({ value, onChange, originalIdentifier, @@ -547,6 +556,7 @@ function IdentifierInput({ onValidationChange, isEditingExisting = false, }: IdentifierInputProps) { + const errorId = useId() const { isChecking, error, isValid } = useIdentifierValidation( value, originalIdentifier, @@ -590,6 +600,8 @@ function IdentifierInput({ onChange={(e) => handleChange(e.target.value)} required disabled={disabled} + aria-invalid={Boolean(error)} + aria-describedby={error ? errorId : undefined} className={cn( 'rounded-none border-0 bg-transparent pl-0 shadow-none disabled:bg-transparent disabled:opacity-100', (isChecking || (isValid && value)) && 'pr-8' @@ -617,7 +629,7 @@ function IdentifierInput({ )} - {error && {error}} + {error && {error}} {isEditingExisting && value ? ( <> @@ -745,9 +757,7 @@ function AuthSelector({ } /> {canRevealPassword && revealPasswordMutation.isError && ( - - Failed to load the current password - + Failed to load the current password )} {getPasswordHelperText(hasExistingPassword)} @@ -772,7 +782,7 @@ function AuthSelector({ )} - {error && {error}} + {error && {error}} ) } diff --git a/packages/emcn/src/components/chip-input/chip-input.test.tsx b/packages/emcn/src/components/chip-input/chip-input.test.tsx index a1d70082ff6..542ce50f165 100644 --- a/packages/emcn/src/components/chip-input/chip-input.test.tsx +++ b/packages/emcn/src/components/chip-input/chip-input.test.tsx @@ -95,6 +95,7 @@ describe('chip form controls', () => { { expect(input.parentElement?.className).toContain('h-9') expect(input.parentElement?.className).toContain('border-[var(--text-error)]') expect(textareaRef.current?.rows).toBe(3) + expect(textareaRef.current?.className).toContain('font-mono') expect(textareaRef.current?.className).toContain('min-h-[80px]') act(() => container?.querySelector('button')?.click()) expect(submit).not.toHaveBeenCalled() diff --git a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx index 69f8ab0793e..d741405d056 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx @@ -280,6 +280,61 @@ describe('ChipModalBody', () => { }) }) +describe('ChipModalField composition', () => { + it.each(['vertical', 'horizontal'] as const)( + 'retains labels and feedback in %s fields', + (orientation) => { + mount( + Help} + orientation={orientation} + value='Review' + onChange={() => {}} + required + error='Enter a valid name' + hint='Hidden while invalid' + /> + ) + const input = container!.querySelector('input')! + expect(input.labels?.[0].textContent).toBe('Name*') + expect(input.getAttribute('aria-required')).toBe('true') + expect(input.getAttribute('aria-invalid')).toBe('true') + const error = document.getElementById(input.getAttribute('aria-describedby')!)! + expect(error.textContent).toBe('Enter a valid name') + expect(error.getAttribute('role')).toBe('alert') + expect(container!.textContent).not.toContain('Hidden while invalid') + expect(input.labels?.[0].contains(buttonByText('Help'))).toBe(false) + act(() => input.focus()) + expect(document.activeElement).toBe(input) + } + ) + + it('forwards monospace and height settings to a labeled textarea', () => { + mount( + {}} + mono + minHeight={120} + rows={4} + hint='Enter JSON' + /> + ) + const textarea = container!.querySelector('textarea')! + expect(textarea.labels?.[0].textContent).toBe('JSON') + expect(textarea.className).toContain('font-mono') + expect(textarea.style.minHeight).toBe('120px') + expect(textarea.rows).toBe(4) + expect(document.getElementById(textarea.getAttribute('aria-describedby')!)?.textContent).toBe( + 'Enter JSON' + ) + }) +}) + describe('ChipModalField file actions', () => { it('names each upload action with its field title', () => { mount( @@ -501,7 +556,13 @@ describe('ChipModal default actions', () => { {}} srTitle='Visible action'> {}}>Visible action - {}} /> + {}} + /> ) + const titleContent = titleActions ? ( + + {label} + {titleActions} + + ) : ( + label + ) + const control = renderChipModalControl(props, id, errorId, hintId) + return ( - {titleActions ? ( - - {label} - {titleActions} - + {horizontal ? ( + <> + {titleContent} + {control} + > ) : ( - label + <> + {titleContent} + {control} + > )} - {renderChipModalControl(props, id, errorId, hintId)} {error && props.type !== 'emails' ? ( - + {error} ) : hint ? ( - + {hint} ) : null} @@ -903,7 +950,7 @@ function renderChipModalControl( disabled={props.disabled} viewOnly={props.viewOnly} resizable={props.resizable} - className={props.mono ? 'font-mono' : undefined} + monospace={props.mono} style={props.minHeight ? { minHeight: props.minHeight } : undefined} {...aria} /> @@ -1815,6 +1862,7 @@ export { ChipModalBody, ChipModalError, ChipModalField, + chipModalFieldVariants, ChipModalFooter, ChipModalHeader, ChipModalPromptBody, diff --git a/packages/emcn/src/components/chip-textarea/chip-textarea.tsx b/packages/emcn/src/components/chip-textarea/chip-textarea.tsx index 8f891a11031..471d3eed28a 100644 --- a/packages/emcn/src/components/chip-textarea/chip-textarea.tsx +++ b/packages/emcn/src/components/chip-textarea/chip-textarea.tsx @@ -27,6 +27,8 @@ export interface ChipTextareaProps extends Omit, 'size'> { /** Marks the field invalid; swaps the border to the error token. */ error?: boolean + /** Uses the shared monospace font for JSON, keys, and other structured text. */ + monospace?: boolean /** Allows vertical user resizing. Off by default. */ resizable?: boolean /** @@ -41,7 +43,18 @@ export interface ChipTextareaProps /** Forwards its ref to the underlying ``, exactly like a native textarea. */ export const ChipTextarea = React.forwardRef( - ({ className, error, resizable = false, viewOnly = false, readOnly, ...props }, ref) => ( + ( + { + className, + error, + monospace = false, + resizable = false, + viewOnly = false, + readOnly, + ...props + }, + ref + ) => (
`/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. +- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. - **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array. - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. diff --git a/.cursor/rules/emcn-components.mdc b/.cursor/rules/emcn-components.mdc index 75fe49a83e3..9b19e89c154 100644 --- a/.cursor/rules/emcn-components.mdc +++ b/.cursor/rules/emcn-components.mdc @@ -25,10 +25,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`Chip` / `ChipLink`** — the pill button (`` / Next ``). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`. - **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner ``); `className` styles the chrome wrapper. - **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit. -- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`). +- **`ChipTextarea`** — multi-line sibling. `monospace` for structured text, `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`). - **`ChipSelect`** — canonical menu selection. Single selection uses `value`/`onChange`; `multiSelect` uses `multiSelectValues`/`onMultiSelectChange`, checkbox rows, and stays open. Supports groups, search aliases, rich labels/icons, disabled options, optional single-selection checks, and trigger refs. Rich labels require a nonempty `searchTerms` array containing their visible text, followed by any aliases; plain string/number labels are searchable automatically. `showAllOption` clears to an empty selection; without it, use `placeholder` for the empty state. Owns its chevron. `dropdownWidth` accepts `'trigger'`, `'content'`, or pixels; `modal={false}` keeps outside-dialog filters non-modal, while nested dialogs retain their focus lock. - **`ChipCombobox`** — shared chip styling over the separate `Combobox` engine for editable values, asynchronous results, and virtualized lists. Plain menu selection, including searchable menus, uses `ChipSelect`. -- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. +- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. - **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array. - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index bb09919dc63..d032a28cf95 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -1020,7 +1020,8 @@ export default function ResumeExecutionPage({ placeholder='{"example": "value"}' rows={6} spellCheck={false} - className='min-h-[180px] font-mono' + monospace + className='min-h-[180px]' /> diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx index d01953901f4..959e9a3e6aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx @@ -275,7 +275,8 @@ function ClientCredentialAccountModalForm({ value={value} onChange={(event) => setField(field.id, event.target.value)} placeholder={field.placeholder} - className='min-h-[120px] font-mono' + monospace + className='min-h-[120px]' // Browser spell-check and autofill ship textarea contents to // third-party services — an exfiltration route for a pasted // private key. `ChipModalField type='textarea'` exposes none diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index d1d3ceb4b4c..155a417f5bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -763,7 +763,8 @@ export function McpServerFormModal({ type='button' variant='ghost' onClick={() => setShowAdvanced((v) => !v)} - className='gap-1 self-start py-0 text-small' + size='inline' + className='gap-1 self-start' > {showAdvanced ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index 82c1009e63d..9e00fc9c38a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -83,18 +83,16 @@ export function NoOrganizationView({ Team URL - - - sim.ai/team/ - - setOrgSlug(e.target.value)} - placeholder='my-team' - className='rounded-l-none' - /> - + setOrgSlug(e.target.value)} + placeholder='my-team' + className='mt-1' + startAdornment={ + sim.ai/team/ + } + /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx index e72cdce34ad..694361bc085 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx @@ -95,7 +95,7 @@ export function LockSettingsModal({ {field.label} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx new file mode 100644 index 00000000000..039844dfa4d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx @@ -0,0 +1,86 @@ +import type { ComponentProps, PropsWithChildren } from 'react' +import { JSDOM } from 'jsdom' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +const validation = vi.hoisted(() => ({ + current: { isChecking: false, error: 'Use lowercase letters', isValid: false } as { + isChecking: boolean + error: string | null + isValid: boolean + }, +})) + +vi.mock('@sim/emcn', () => ({ + Input: (props: ComponentProps<'input'>) => , + Label: (props: ComponentProps<'label'>) => ( + + {props.children} + + ), + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + Tooltip: { + Root: ({ children }: PropsWithChildren) => <>{children}>, + Trigger: ({ children }: PropsWithChildren) => <>{children}>, + Content: ({ children }: PropsWithChildren) => <>{children}>, + }, +})) +vi.mock('@sim/emcn/icons', () => ({ Check: () => null, TriangleAlert: () => null })) +vi.mock('@sim/logger', () => ({ createLogger: () => ({}) })) +vi.mock('@/components/ui', () => ({ GeneratedPasswordInput: () => null })) +vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({}) })) +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.ai', + getEmailDomain: () => 'sim.ai', +})) +vi.mock('@/lib/messaging/email/validation', () => ({ validateAllowlistEntry: () => true })) +vi.mock('@/lib/workflows/streaming/output-selector', () => ({ + formatInternalOutputSelector: () => '', +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select', + () => ({ + OutputSelect: () => null, + }) +) +vi.mock('@/hooks/queries/chats', () => ({ + useCreateChat: () => ({}), + useDeleteChat: () => ({}), + useRevealChatPassword: () => ({}), + useUpdateChat: () => ({}), +})) +vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({}) })) +vi.mock('./hooks', () => ({ useIdentifierValidation: () => validation.current })) + +import { IdentifierInput } from './chat' + +function renderIdentifier() { + return new JSDOM(renderToStaticMarkup()) + .window.document +} + +describe('deploy URL field error', () => { + it('announces and associates the URL validation error with its input', () => { + validation.current = { isChecking: false, error: 'Use lowercase letters', isValid: false } + const document = renderIdentifier() + const input = document.querySelector('#chat-url') + const alert = document.querySelector('[role="alert"]') + + expect(alert?.textContent).toBe('Use lowercase letters') + expect(alert?.className).toBe('mt-[6.5px] text-[var(--text-error)] text-caption') + expect(input?.getAttribute('aria-invalid')).toBe('true') + expect(input?.getAttribute('aria-describedby')).toBe(alert?.id) + expect(alert?.id).toBeTruthy() + expect(document.querySelector('label')?.htmlFor).toBe(input?.id) + }) + + it('omits the error relationship when the URL is valid', () => { + validation.current = { isChecking: false, error: null, isValid: true } + const document = renderIdentifier() + const input = document.querySelector('#chat-url') + + expect(document.querySelector('[role="alert"]')).toBeNull() + expect(input?.getAttribute('aria-invalid')).toBe('false') + expect(input?.hasAttribute('aria-describedby')).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index b9dfb3271a2..3caefaad692 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useId, useRef, useState } from 'react' import { ChipButtonGroup, ChipButtonGroupItem, @@ -49,6 +49,19 @@ const logger = createLogger('ChatDeploy') const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/ +interface DeployFieldErrorProps { + children: ReactNode + id?: string +} + +function DeployFieldError({ children, id }: DeployFieldErrorProps) { + return ( + + {children} + + ) +} + interface ChatDeployProps { workflowId: string deploymentInfo: { @@ -382,11 +395,7 @@ export function ChatDeploy({ className='w-full' disablePortal /> - {errors.outputBlocks && ( - - {errors.outputBlocks} - - )} + {errors.outputBlocks && {errors.outputBlocks}} @@ -539,7 +548,7 @@ const getDomainPrefix = (() => { return () => prefix })() -function IdentifierInput({ +export function IdentifierInput({ value, onChange, originalIdentifier, @@ -547,6 +556,7 @@ function IdentifierInput({ onValidationChange, isEditingExisting = false, }: IdentifierInputProps) { + const errorId = useId() const { isChecking, error, isValid } = useIdentifierValidation( value, originalIdentifier, @@ -590,6 +600,8 @@ function IdentifierInput({ onChange={(e) => handleChange(e.target.value)} required disabled={disabled} + aria-invalid={Boolean(error)} + aria-describedby={error ? errorId : undefined} className={cn( 'rounded-none border-0 bg-transparent pl-0 shadow-none disabled:bg-transparent disabled:opacity-100', (isChecking || (isValid && value)) && 'pr-8' @@ -617,7 +629,7 @@ function IdentifierInput({ )} - {error && {error}} + {error && {error}} {isEditingExisting && value ? ( <> @@ -745,9 +757,7 @@ function AuthSelector({ } /> {canRevealPassword && revealPasswordMutation.isError && ( - - Failed to load the current password - + Failed to load the current password )} {getPasswordHelperText(hasExistingPassword)} @@ -772,7 +782,7 @@ function AuthSelector({
`/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. - **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array. - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. diff --git a/.cursor/rules/emcn-components.mdc b/.cursor/rules/emcn-components.mdc index 75fe49a83e3..9b19e89c154 100644 --- a/.cursor/rules/emcn-components.mdc +++ b/.cursor/rules/emcn-components.mdc @@ -25,10 +25,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`Chip` / `ChipLink`** — the pill button (`` / Next ``). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`. - **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner ``); `className` styles the chrome wrapper. - **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit. -- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`). +- **`ChipTextarea`** — multi-line sibling. `monospace` for structured text, `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`). - **`ChipSelect`** — canonical menu selection. Single selection uses `value`/`onChange`; `multiSelect` uses `multiSelectValues`/`onMultiSelectChange`, checkbox rows, and stays open. Supports groups, search aliases, rich labels/icons, disabled options, optional single-selection checks, and trigger refs. Rich labels require a nonempty `searchTerms` array containing their visible text, followed by any aliases; plain string/number labels are searchable automatically. `showAllOption` clears to an empty selection; without it, use `placeholder` for the empty state. Owns its chevron. `dropdownWidth` accepts `'trigger'`, `'content'`, or pixels; `modal={false}` keeps outside-dialog filters non-modal, while nested dialogs retain their focus lock. - **`ChipCombobox`** — shared chip styling over the separate `Combobox` engine for editable values, asynchronous results, and virtualized lists. Plain menu selection, including searchable menus, uses `ChipSelect`. -- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. +- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. - **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array. - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index bb09919dc63..d032a28cf95 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -1020,7 +1020,8 @@ export default function ResumeExecutionPage({ placeholder='{"example": "value"}' rows={6} spellCheck={false} - className='min-h-[180px] font-mono' + monospace + className='min-h-[180px]' /> diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx index d01953901f4..959e9a3e6aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx @@ -275,7 +275,8 @@ function ClientCredentialAccountModalForm({ value={value} onChange={(event) => setField(field.id, event.target.value)} placeholder={field.placeholder} - className='min-h-[120px] font-mono' + monospace + className='min-h-[120px]' // Browser spell-check and autofill ship textarea contents to // third-party services — an exfiltration route for a pasted // private key. `ChipModalField type='textarea'` exposes none diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index d1d3ceb4b4c..155a417f5bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -763,7 +763,8 @@ export function McpServerFormModal({ type='button' variant='ghost' onClick={() => setShowAdvanced((v) => !v)} - className='gap-1 self-start py-0 text-small' + size='inline' + className='gap-1 self-start' > {showAdvanced ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index 82c1009e63d..9e00fc9c38a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -83,18 +83,16 @@ export function NoOrganizationView({ Team URL - - - sim.ai/team/ - - setOrgSlug(e.target.value)} - placeholder='my-team' - className='rounded-l-none' - /> - + setOrgSlug(e.target.value)} + placeholder='my-team' + className='mt-1' + startAdornment={ + sim.ai/team/ + } + /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx index e72cdce34ad..694361bc085 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx @@ -95,7 +95,7 @@ export function LockSettingsModal({ {field.label} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx new file mode 100644 index 00000000000..039844dfa4d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx @@ -0,0 +1,86 @@ +import type { ComponentProps, PropsWithChildren } from 'react' +import { JSDOM } from 'jsdom' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +const validation = vi.hoisted(() => ({ + current: { isChecking: false, error: 'Use lowercase letters', isValid: false } as { + isChecking: boolean + error: string | null + isValid: boolean + }, +})) + +vi.mock('@sim/emcn', () => ({ + Input: (props: ComponentProps<'input'>) => , + Label: (props: ComponentProps<'label'>) => ( + + {props.children} + + ), + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + Tooltip: { + Root: ({ children }: PropsWithChildren) => <>{children}>, + Trigger: ({ children }: PropsWithChildren) => <>{children}>, + Content: ({ children }: PropsWithChildren) => <>{children}>, + }, +})) +vi.mock('@sim/emcn/icons', () => ({ Check: () => null, TriangleAlert: () => null })) +vi.mock('@sim/logger', () => ({ createLogger: () => ({}) })) +vi.mock('@/components/ui', () => ({ GeneratedPasswordInput: () => null })) +vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({}) })) +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.ai', + getEmailDomain: () => 'sim.ai', +})) +vi.mock('@/lib/messaging/email/validation', () => ({ validateAllowlistEntry: () => true })) +vi.mock('@/lib/workflows/streaming/output-selector', () => ({ + formatInternalOutputSelector: () => '', +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select', + () => ({ + OutputSelect: () => null, + }) +) +vi.mock('@/hooks/queries/chats', () => ({ + useCreateChat: () => ({}), + useDeleteChat: () => ({}), + useRevealChatPassword: () => ({}), + useUpdateChat: () => ({}), +})) +vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({}) })) +vi.mock('./hooks', () => ({ useIdentifierValidation: () => validation.current })) + +import { IdentifierInput } from './chat' + +function renderIdentifier() { + return new JSDOM(renderToStaticMarkup()) + .window.document +} + +describe('deploy URL field error', () => { + it('announces and associates the URL validation error with its input', () => { + validation.current = { isChecking: false, error: 'Use lowercase letters', isValid: false } + const document = renderIdentifier() + const input = document.querySelector('#chat-url') + const alert = document.querySelector('[role="alert"]') + + expect(alert?.textContent).toBe('Use lowercase letters') + expect(alert?.className).toBe('mt-[6.5px] text-[var(--text-error)] text-caption') + expect(input?.getAttribute('aria-invalid')).toBe('true') + expect(input?.getAttribute('aria-describedby')).toBe(alert?.id) + expect(alert?.id).toBeTruthy() + expect(document.querySelector('label')?.htmlFor).toBe(input?.id) + }) + + it('omits the error relationship when the URL is valid', () => { + validation.current = { isChecking: false, error: null, isValid: true } + const document = renderIdentifier() + const input = document.querySelector('#chat-url') + + expect(document.querySelector('[role="alert"]')).toBeNull() + expect(input?.getAttribute('aria-invalid')).toBe('false') + expect(input?.hasAttribute('aria-describedby')).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index b9dfb3271a2..3caefaad692 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useId, useRef, useState } from 'react' import { ChipButtonGroup, ChipButtonGroupItem, @@ -49,6 +49,19 @@ const logger = createLogger('ChatDeploy') const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/ +interface DeployFieldErrorProps { + children: ReactNode + id?: string +} + +function DeployFieldError({ children, id }: DeployFieldErrorProps) { + return ( + + {children} + + ) +} + interface ChatDeployProps { workflowId: string deploymentInfo: { @@ -382,11 +395,7 @@ export function ChatDeploy({ className='w-full' disablePortal /> - {errors.outputBlocks && ( - - {errors.outputBlocks} - - )} + {errors.outputBlocks && {errors.outputBlocks}} @@ -539,7 +548,7 @@ const getDomainPrefix = (() => { return () => prefix })() -function IdentifierInput({ +export function IdentifierInput({ value, onChange, originalIdentifier, @@ -547,6 +556,7 @@ function IdentifierInput({ onValidationChange, isEditingExisting = false, }: IdentifierInputProps) { + const errorId = useId() const { isChecking, error, isValid } = useIdentifierValidation( value, originalIdentifier, @@ -590,6 +600,8 @@ function IdentifierInput({ onChange={(e) => handleChange(e.target.value)} required disabled={disabled} + aria-invalid={Boolean(error)} + aria-describedby={error ? errorId : undefined} className={cn( 'rounded-none border-0 bg-transparent pl-0 shadow-none disabled:bg-transparent disabled:opacity-100', (isChecking || (isValid && value)) && 'pr-8' @@ -617,7 +629,7 @@ function IdentifierInput({ )}
`/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. +- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled ``/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. - **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array. - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index bb09919dc63..d032a28cf95 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -1020,7 +1020,8 @@ export default function ResumeExecutionPage({ placeholder='{"example": "value"}' rows={6} spellCheck={false} - className='min-h-[180px] font-mono' + monospace + className='min-h-[180px]' />
`/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`. - **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array. - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index bb09919dc63..d032a28cf95 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -1020,7 +1020,8 @@ export default function ResumeExecutionPage({ placeholder='{"example": "value"}' rows={6} spellCheck={false} - className='min-h-[180px] font-mono' + monospace + className='min-h-[180px]' />
+ {children} +
- {errors.outputBlocks} -
{error}
{isEditingExisting && value ? ( <> @@ -745,9 +757,7 @@ function AuthSelector({ } /> {canRevealPassword && revealPasswordMutation.isError && ( -
- Failed to load the current password -
{getPasswordHelperText(hasExistingPassword)} @@ -772,7 +782,7 @@ function AuthSelector({
+
{hint}