Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .claude/rules/emcn-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). 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 `<input>`); `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 `<div>` + hand-rolled `<p>`/`<label>` 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 `<div>` + hand-rolled `<p>`/`<label>` 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.
Expand Down Expand Up @@ -70,3 +70,7 @@ geometry only; colour, radius and SVG stroke continue to come from the selected
Use `shape='round'` for circular actions, or omit it to retain the current radius.
`size='inline'` is a 20px-high action with caption typography and compact horizontal
padding. Prefer these supported props to size, padding and radius overrides.

### Chip sizing and centered actions

`Chip`, `ChipLink`, and `ChipInput` retain their default 30px height. Use `size="lg"` for the existing auth-scale 36px controls; the larger size follows the root font size. `Chip` and `ChipLink` accept `align="center"` to center their icon/label group, including full-width form actions. Long labels still shrink and retain their overflow treatment. Avoid descendant flex overrides to center chip labels. The default geometry exports remain unchanged for static consumers.
8 changes: 6 additions & 2 deletions .cursor/rules/emcn-components.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). 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 `<input>`); `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 `<div>` + hand-rolled `<p>`/`<label>` 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 `<div>` + hand-rolled `<p>`/`<label>` 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.
Expand Down Expand Up @@ -71,3 +71,7 @@ geometry only; colour, radius and SVG stroke continue to come from the selected
Use `shape='round'` for circular actions, or omit it to retain the current radius.
`size='inline'` is a 20px-high action with caption typography and compact horizontal
padding. Prefer these supported props to size, padding and radius overrides.

### Chip sizing and centered actions

`Chip`, `ChipLink`, and `ChipInput` retain their default 30px height. Use `size="lg"` for the existing auth-scale 36px controls; the larger size follows the root font size. `Chip` and `ChipLink` accept `align="center"` to center their icon/label group, including full-width form actions. Long labels still shrink and retain their overflow treatment. Avoid descendant flex overrides to center chip labels. The default geometry exports remain unchanged for static consumers.
17 changes: 4 additions & 13 deletions apps/sim/app/(auth)/components/auth-input.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
'use client'

import * as React from 'react'
import { ChipInput, type ChipInputProps, cn } from '@sim/emcn'
import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants'
import { ChipInput, type ChipInputProps } from '@sim/emcn'

/**
* The auth text field — a {@link ChipInput} raised to the auth control height
* ({@link AUTH_CONTROL_HEIGHT}) so every labeled field on the auth and invite
* surfaces shares one slightly-taller geometry. All chip props pass through
* (`error`, `endAdornment`, `icon`, …); only the height is owned here, and a
* caller's `className` (layout only) still composes on top.
*/
export const AuthInput = React.forwardRef<HTMLInputElement, ChipInputProps>(
({ className, ...props }, ref) => (
<ChipInput ref={ref} className={cn(AUTH_CONTROL_HEIGHT, className)} {...props} />
)
/** Auth fields use the larger shared chip size while retaining native input props and refs. */
export const AuthInput = React.forwardRef<HTMLInputElement, Omit<ChipInputProps, 'size'>>(
(props, ref) => <ChipInput {...props} ref={ref} size='lg' />
)

AuthInput.displayName = 'AuthInput'
Original file line number Diff line number Diff line change
Expand Up @@ -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]'
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type LandingCtaSize = 'compact' | 'default' | 'display'

export type LandingCtaSection = PostHogEventMap['landing_cta_clicked']['section']

interface LandingCtaLinkProps extends Omit<ChipLinkProps, 'variant'> {
interface LandingCtaLinkProps extends Omit<ChipLinkProps, 'variant' | 'size'> {
size?: LandingCtaSize
variant?: 'primary' | 'outline'
/** Adds the animated chevron used by demo actions. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<ChevronDown className='size-[14px]' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const VIEWER_MASK_LENGTH = 10

type SecretValueFieldProps = Omit<
ComponentProps<'input'>,
'type' | 'value' | 'onChange' | 'readOnly' | 'style'
'type' | 'value' | 'onChange' | 'readOnly' | 'style' | 'size'
> & {
/** The chip owns field styling; callers use className for layout. */
style?: never
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,16 @@ export function NoOrganizationView({

<div>
<Label htmlFor='orgSlug'>Team URL</Label>
<div className='mt-1 flex items-center'>
<div className='rounded-l-[6px] border border-[var(--border-1)] border-r-0 bg-[var(--surface-4)] px-3 py-1.5 text-[var(--text-muted)] text-small'>
sim.ai/team/
</div>
<ChipInput
id='orgSlug'
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
placeholder='my-team'
className='rounded-l-none'
/>
</div>
<ChipInput
id='orgSlug'
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
placeholder='my-team'
className='mt-1'
startAdornment={
<span className='shrink-0 text-[var(--text-muted)] text-small'>sim.ai/team/</span>
}
/>
</div>

<div className='flex flex-col gap-2'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function LockSettingsModal({
<ChipModalField
key={field.key}
type='custom'
className='flex-row items-center justify-between'
orientation='horizontal'
title={
<span className='inline-flex items-center gap-1.5'>
{field.label}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'>) => <input {...props} />,
Label: (props: ComponentProps<'label'>) => (
<label htmlFor={props.htmlFor} className={props.className}>
{props.children}
</label>
),
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(<IdentifierInput value='bad path' onChange={vi.fn()} />))
.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<HTMLInputElement>('#chat-url')
const alert = document.querySelector<HTMLElement>('[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<HTMLInputElement>('#chat-url')

expect(document.querySelector('[role="alert"]')).toBeNull()
expect(input?.getAttribute('aria-invalid')).toBe('false')
expect(input?.hasAttribute('aria-describedby')).toBe(false)
})
})
Loading
Loading