Skip to content
Open
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
14 changes: 14 additions & 0 deletions .claude/rules/emcn-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,17 @@ Declare keyboard intent on the action-owning primitive; never add document-level
- Use Radix UI primitives for accessibility. Export the component and its `variants` (when using CVA). Document with TSDoc + a usage example.

Color tokens and icon-size conventions are canonical in `.claude/rules/sim-styling.md` — follow it rather than restating.


## Ordinary Button action geometry

`Button` retains its existing appearance variants. For square actions use `iconSize`:
`compact` (24px on the spacing scale), `compact-fixed` (24px), `regular` (28px),
`roomy` (32px), or `touch` (40px). These values follow the root spacing scale;
only `compact-fixed` stays fixed when root text is enlarged.
Use `{ base: 'touch', sm: 'regular' }` for mobile/desktop targets. These props own
geometry only; colour, radius and SVG stroke continue to come from the selected
`variant` and `size`. `iconPadding` explicitly overrides the zero-padding geometry.
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.
14 changes: 14 additions & 0 deletions .cursor/rules/emcn-components.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,17 @@ Declare keyboard intent on the action-owning primitive; never add document-level
- Use Radix UI primitives for accessibility. Export the component and its `variants` (when using CVA). Document with TSDoc + a usage example.

Color tokens and icon-size conventions are canonical in `.claude/rules/sim-styling.md` — follow it rather than restating.


## Ordinary Button action geometry

`Button` retains its existing appearance variants. For square actions use `iconSize`:
`compact` (24px on the spacing scale), `compact-fixed` (24px), `regular` (28px),
`roomy` (32px), or `touch` (40px). These values follow the root spacing scale;
only `compact-fixed` stays fixed when root text is enlarged.
Use `{ base: 'touch', sm: 'regular' }` for mobile/desktop targets. These props own
geometry only; colour, radius and SVG stroke continue to come from the selected
`variant` and `size`. `iconPadding` explicitly overrides the zero-padding geometry.
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.
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
size='sm'
onClick={() => remove(option.id)}
iconPadding='sm'
className='size-7 shrink-0'
iconSize='regular'
className='shrink-0'
aria-label={`Remove ${option.name || 'option'}`}
>
<X className='size-[12px]' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,8 @@ const FilterRuleRow = memo(function FilterRuleRow({
size='sm'
onClick={() => onRemove(rule.id)}
iconPadding='sm'
className='size-7 shrink-0'
iconSize='regular'
className='shrink-0'
aria-label='Remove filter'
>
<X className='size-[12px]' />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type ComponentProps, forwardRef } from 'react'
import { Button, chipHoverSurfaceClass, cn } from '@sim/emcn'

interface CanvasControlButtonProps
extends Omit<
ComponentProps<typeof Button>,
'variant' | 'size' | 'iconSize' | 'iconPadding' | 'className'
> {
active?: boolean
'aria-label': string
}

/** Fixed-size action in the canvas navigation toolbar. */
export const CanvasControlButton = forwardRef<HTMLButtonElement, CanvasControlButtonProps>(
({ active = false, ...props }, ref) => (
<Button
{...props}
ref={ref}
variant={active ? 'active' : 'ghost'}
className={cn('size-[28px] rounded-sm p-0', !active && chipHoverSurfaceClass)}
/>
)
)

CanvasControlButton.displayName = 'CanvasControlButton'
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useSession } from '@/lib/auth/auth-client'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { CanvasControlButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/canvas-control-button'
import { useShowActionBar, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
Expand Down Expand Up @@ -109,17 +110,13 @@ export const WorkflowControls = memo(function WorkflowControls() {
<PopoverTrigger asChild>
<div className='flex cursor-pointer items-center gap-1'>
<Tooltip.Trigger asChild>
<Button
aria-label={mode === 'hand' ? 'Mover' : 'Pointer'}
className='size-[28px] rounded-sm p-0'
variant='active'
>
<CanvasControlButton aria-label={mode === 'hand' ? 'Mover' : 'Pointer'} active>
{mode === 'hand' ? (
<Hand className='size-[14px]' />
) : (
<Cursor className='size-[14px]' />
)}
</Button>
</CanvasControlButton>
</Tooltip.Trigger>
<Button
aria-label='Change canvas mode'
Expand Down Expand Up @@ -160,15 +157,9 @@ export const WorkflowControls = memo(function WorkflowControls() {

<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
aria-label='Undo'
variant='ghost'
className={cn('size-[28px] rounded-sm p-0', chipHoverSurfaceClass)}
onClick={undo}
disabled={!canUndo}
>
<CanvasControlButton aria-label='Undo' onClick={undo} disabled={!canUndo}>
<Undo className='size-[14px]' />
</Button>
</CanvasControlButton>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘Z'>Undo</Tooltip.Shortcut>
Expand All @@ -177,15 +168,9 @@ export const WorkflowControls = memo(function WorkflowControls() {

<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
aria-label='Redo'
variant='ghost'
className={cn('size-[28px] rounded-sm p-0', chipHoverSurfaceClass)}
onClick={redo}
disabled={!canRedo}
>
<CanvasControlButton aria-label='Redo' onClick={redo} disabled={!canRedo}>
<Redo className='size-[14px]' />
</Button>
</CanvasControlButton>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘⇧Z'>Redo</Tooltip.Shortcut>
Expand All @@ -196,14 +181,9 @@ export const WorkflowControls = memo(function WorkflowControls() {

<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
aria-label='Fit to View'
variant='ghost'
className={cn('size-[28px] rounded-sm p-0', chipHoverSurfaceClass)}
onClick={handleFitToView}
>
<CanvasControlButton aria-label='Fit to View' onClick={handleFitToView}>
<SelectAll className='size-[14px]' />
</Button>
</CanvasControlButton>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘⇧F'>Fit to View</Tooltip.Shortcut>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const bulkActionButtonVariants = cva(
)

export interface BulkActionButtonProps
extends Omit<ButtonProps, 'variant' | 'size' | 'iconPadding' | 'iconSize'> {
extends Omit<ButtonProps, 'variant' | 'size' | 'iconPadding' | 'iconSize' | 'shape'> {
/** Accessible name for the icon action; tooltip content is supplied separately. */
'aria-label': string
/**
Expand Down
86 changes: 85 additions & 1 deletion packages/emcn/src/components/button/button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/** @vitest-environment node */
/** @vitest-environment jsdom */
/** biome-ignore assist/source/organizeImports: Preserve the documented core/external/UI import order. */
import { act, createRef } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { Button } from '@sim/emcn'
Expand All @@ -22,6 +24,9 @@ describe('Button iconSize', () => {
for (const [iconSize, previousClass] of [
['compact', 'size-6 p-0'],
['compact-fixed', 'size-[24px] p-0'],
['regular', 'size-7 p-0'],
['roomy', 'size-8 p-0'],
['touch', 'size-10 p-0'],
] as const) {
it.each(TREATMENTS)(
`preserves the ${iconSize} treatment with size=$size and variant=$variant`,
Expand Down Expand Up @@ -65,3 +70,82 @@ describe('Button iconSize', () => {
expect(markup).not.toContain('iconPadding')
})
})

describe('Button shared action geometry', () => {
it('composes responsive geometry, explicit padding and round shape without changing icon treatment', () => {
const markup = renderToStaticMarkup(
<Button
variant='ghost'
size='icon'
iconSize={{ base: 'touch', sm: 'regular' }}
iconPadding='sm'
shape='round'
aria-label='Edit'
>
<svg strokeWidth={1.55} />
</Button>
)
expect(markup).toContain('size-10')
expect(markup).toContain('sm:size-7')
expect(markup).not.toContain('size-[20px]')
expect(markup).toContain('p-1')
expect(markup).not.toContain('p-0')
expect(markup).toContain('rounded-full')
expect(markup).not.toContain('rounded-sm')
expect(markup).toContain('[stroke-width:1.25]')
expect(markup).toContain('text-[var(--text-icon-muted)]')
expect(markup).not.toMatch(/(?:iconSize|iconPadding|shape)=/)
})

it('retains an inline caption size and supports a base-only responsive value', () => {
const inline = renderToStaticMarkup(<Button size='inline'>Generate</Button>)
expect(inline).toContain('h-[20px]')
expect(inline).toContain('text-caption')
expect(inline).toContain('px-1.5 py-0')
const baseOnly = renderToStaticMarkup(<Button iconSize={{ base: 'roomy' }} aria-label='Run' />)
expect(baseOnly).toContain('size-8')
expect(baseOnly).not.toContain('sm:size')
})

it('forwards refs and native focus, submission and disabled behavior with responsive sizing', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const ref = createRef<HTMLButtonElement>()
let submissions = 0
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const render = (disabled: boolean) =>
act(() =>
root.render(
<form
onSubmit={(event) => {
event.preventDefault()
submissions++
}}
>
<Button
ref={ref}
type='submit'
iconSize={{ base: 'touch', sm: 'regular' }}
disabled={disabled}
aria-label='Apply'
/>
</form>
)
)
try {
render(false)
ref.current?.focus()
expect(document.activeElement).toBe(ref.current)
act(() => ref.current?.click())
expect(submissions).toBe(1)
render(true)
act(() => ref.current?.click())
expect(submissions).toBe(1)
expect(ref.current?.disabled).toBe(true)
} finally {
act(() => root.unmount())
container.remove()
}
})
})
42 changes: 37 additions & 5 deletions packages/emcn/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,17 @@ const buttonVariants = cva(
sm: 'px-1.5 py-1 text-[length:11px]',
md: 'px-2 py-1.5 text-[length:12px]',
icon: 'size-[20px] rounded-sm p-0 [&_svg]:[stroke-width:1.25]',
inline: 'h-[20px] px-1.5 py-0 text-caption',
},
iconSize: {
compact: 'size-6 p-0',
'compact-fixed': 'size-[24px] p-0',
regular: 'size-7 p-0',
roomy: 'size-8 p-0',
touch: 'size-10 p-0',
},
shape: {
round: 'rounded-full',
},
iconPadding: {
sm: 'p-1',
Expand All @@ -76,18 +83,31 @@ const buttonVariants = cva(
}
)

type ButtonIconSize = NonNullable<VariantProps<typeof buttonVariants>['iconSize']>

const responsiveIconSizes = {
compact: 'sm:size-6',
'compact-fixed': 'sm:size-[24px]',
regular: 'sm:size-7',
roomy: 'sm:size-8',
touch: 'sm:size-10',
} satisfies Record<ButtonIconSize, string>

export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
Omit<VariantProps<typeof buttonVariants>, 'iconSize'> {
/**
* Square icon-action geometry without changing the selected size's typography,
* corner radius, icon stroke or color. `compact` follows the spacing scale
* (24px at the default root font size); `compact-fixed` stays at 24px.
* Both remove padding; an explicit iconPadding or className can override it.
* Regular, roomy and touch follow the spacing scale (28px, 32px and 40px
* at the default root font size). A responsive
* value changes geometry at the standard sm breakpoint. All remove padding;
* explicit iconPadding or className can override it.
* Omit to retain the selected size's geometry.
* @example <Button variant='ghost' iconSize='compact' aria-label='Remove'><X /></Button>
*/
iconSize?: VariantProps<typeof buttonVariants>['iconSize']
iconSize?: ButtonIconSize | { base: ButtonIconSize; sm?: ButtonIconSize } | null
/**
* Symmetric padding for icon actions whose content or layout determines their size.
* Preserves the selected size's typography, corner radius and icon stroke.
Expand All @@ -98,11 +118,23 @@ export interface ButtonProps
}

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, iconSize, iconPadding, ...props }, ref) => {
({ className, variant, size, iconSize, iconPadding, shape, ...props }, ref) => {
const baseIconSize = typeof iconSize === 'object' ? iconSize?.base : iconSize
const smIconSize = typeof iconSize === 'object' ? iconSize?.sm : undefined
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size, iconSize, iconPadding }), className)}
className={cn(
buttonVariants({
variant,
size,
iconSize: baseIconSize,
iconPadding,
shape,
}),
smIconSize && responsiveIconSizes[smIconSize],
className
)}
{...props}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const composerActionButtonVariants = cva('rounded-full p-0', {
})

export interface ComposerActionButtonProps
extends Omit<ButtonProps, 'variant' | 'size' | 'iconPadding' | 'iconSize'> {
extends Omit<ButtonProps, 'variant' | 'size' | 'iconPadding' | 'iconSize' | 'shape'> {
/** Accessible name for the caller's icon action. */
'aria-label': string
/** 28px by default; `sm` retains compact chat's 22px geometry and hover treatment. */
Expand Down