diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index aaf692544a1..081450193e6 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -117,7 +117,7 @@ Settings pages never use a literal `text-[Npx]` class — always the named Tailw scale token from the `@theme` block in `apps/sim/app/_styles/globals.css` (`text-micro` 10px, `text-xs` 11px, `text-caption` 12px, `text-small` 13px, `text-sm` 14px [Tailwind default, unmodified], `text-base` 15px, `text-md` 16px, `text-lg` 18px -[Tailwind default]). A literal size is either a straight rename to the equivalent +[the exact Tailwind default adopted in globals.css]). A literal size is either a straight rename to the equivalent token (if the pixel value matches one exactly) or a sign the page never migrated — grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and `apps/sim/ee/**` to find stragglers. diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 48d9086a481..64a18ec7d9e 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -47,7 +47,11 @@ setWidth: (width) => { ## Text Scale -Custom font sizes (the `@theme` block in `apps/sim/app/_styles/globals.css`): `text-micro`=10px, `text-xs`=11px, `text-caption`=12px, `text-small`=13px, `text-base`=15px. `text-sm` is Tailwind default 14px. Field titles use `text-small` (13px); hints/errors use `text-caption` (12px). +Use the named scale in `apps/sim/app/_styles/globals.css`: `text-micro`=10px, `text-xs`=11px, `text-caption`=12px, `text-small`=13px, `text-base`=15px, `text-md`=16px, `text-title`=28px, `text-title-lg`=32px, and `text-display`=40px. The explicitly adopted stock entries retain their rem units and default leading: `text-sm`=0.875rem, `text-lg`=1.125rem, `text-xl`=1.25rem, and `text-2xl`=1.5rem (14/18/20/24px at the default root size). Field titles use `text-small`; hints/errors use `text-caption`; workflow titles use `text-md`. + +Preserve explicit line heights when replacing arbitrary sizes. Where the old size inherited its line height, use `leading-[inherit]` if the named utility would otherwise add a different default. Keep document-relative heading/code recipes and the thinking loader's proportional label sizing distinct from ordinary UI text. + +Use `font-inter` within the existing scoped Inter loading boundary; the utility does not load the font globally. Keep monospace for code, JSON, and credentials. Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`. @@ -67,7 +71,7 @@ A scroll region that can hide rows past an edge uses `useScrollEdges` with `scro ## Font Weight -Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body. +Three steps, explicitly adopted from Tailwind's stock scale in globals.css, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class to step *up* from body or to reset meaningful inherited weight. Raw CSS references the same `--font-weight-normal`, `--font-weight-medium`, and `--font-weight-semibold` definitions; their values remain 400/500/600. Never write an arbitrary weight (`font-[380]`, `font-[430]`, `font-[450]`, …), and never set `fontWeight` in an inline `style`. There was previously a CSS-variable weight scale (`--font-weight-base/medium/semibold`, remapping `font-medium` to 440/480) plus seven ad-hoc values clustered between 380 and 500; it was deleted because nothing read as hierarchical. Off-scale values are only acceptable where the design system genuinely cannot reach — react-email templates and the static `apps/sim/emails/broadcasts/*.html`, which email clients render without CSS variables. diff --git a/.cursor/rules/sim-settings-pages.mdc b/.cursor/rules/sim-settings-pages.mdc index 29db1e041e9..b9a39b3d8e3 100644 --- a/.cursor/rules/sim-settings-pages.mdc +++ b/.cursor/rules/sim-settings-pages.mdc @@ -114,7 +114,7 @@ Settings pages never use a literal `text-[Npx]` class — always the named Tailw scale token from the `@theme` block in `apps/sim/app/_styles/globals.css` (`text-micro` 10px, `text-xs` 11px, `text-caption` 12px, `text-small` 13px, `text-sm` 14px [Tailwind default, unmodified], `text-base` 15px, `text-md` 16px, `text-lg` 18px -[Tailwind default]). A literal size is either a straight rename to the equivalent +[the exact Tailwind default adopted in globals.css]). A literal size is either a straight rename to the equivalent token (if the pixel value matches one exactly) or a sign the page never migrated — grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and `apps/sim/ee/**` to find stragglers. diff --git a/.cursor/rules/sim-styling.mdc b/.cursor/rules/sim-styling.mdc index 7f92c255aeb..e3816f07127 100644 --- a/.cursor/rules/sim-styling.mdc +++ b/.cursor/rules/sim-styling.mdc @@ -47,7 +47,11 @@ setWidth: (width) => { ## Text Scale -Custom font sizes (the `@theme` block in `apps/sim/app/_styles/globals.css`): `text-micro`=10px, `text-xs`=11px, `text-caption`=12px, `text-small`=13px, `text-base`=15px. `text-sm` is Tailwind default 14px. Field titles use `text-small` (13px); hints/errors use `text-caption` (12px). +Use the named scale in `apps/sim/app/_styles/globals.css`: `text-micro`=10px, `text-xs`=11px, `text-caption`=12px, `text-small`=13px, `text-base`=15px, `text-md`=16px, `text-title`=28px, `text-title-lg`=32px, and `text-display`=40px. The explicitly adopted stock entries retain their rem units and default leading: `text-sm`=0.875rem, `text-lg`=1.125rem, `text-xl`=1.25rem, and `text-2xl`=1.5rem (14/18/20/24px at the default root size). Field titles use `text-small`; hints/errors use `text-caption`; workflow titles use `text-md`. + +Preserve explicit line heights when replacing arbitrary sizes. Where the old size inherited its line height, use `leading-[inherit]` if the named utility would otherwise add a different default. Keep document-relative heading/code recipes and the thinking loader's proportional label sizing distinct from ordinary UI text. + +Use `font-inter` within the existing scoped Inter loading boundary; the utility does not load the font globally. Keep monospace for code, JSON, and credentials. Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`. @@ -67,7 +71,7 @@ A scroll region that can hide rows past an edge uses `useScrollEdges` with `scro ## Font Weight -Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body. +Three steps, explicitly adopted from Tailwind's stock scale in globals.css, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class to step *up* from body or to reset meaningful inherited weight. Raw CSS references the same `--font-weight-normal`, `--font-weight-medium`, and `--font-weight-semibold` definitions; their values remain 400/500/600. Never write an arbitrary weight (`font-[380]`, `font-[430]`, `font-[450]`, …), and never set `fontWeight` in an inline `style`. There was previously a CSS-variable weight scale (`--font-weight-base/medium/semibold`, remapping `font-medium` to 440/480) plus seven ad-hoc values clustered between 380 and 500; it was deleted because nothing read as hierarchical. Off-scale values are only acceptable where the design system genuinely cannot reach — react-email templates and the static `apps/sim/emails/broadcasts/*.html`, which email clients render without CSS variables. diff --git a/apps/sim/app/(auth)/components/auth-header.tsx b/apps/sim/app/(auth)/components/auth-header.tsx index 803e1b0ec57..1b8d278f9e4 100644 --- a/apps/sim/app/(auth)/components/auth-header.tsx +++ b/apps/sim/app/(auth)/components/auth-header.tsx @@ -14,7 +14,9 @@ interface AuthHeaderProps { export function AuthHeader({ title, description }: AuthHeaderProps) { return (
-

{title}

+

+ {title} +

{description != null && (

{description}

)} diff --git a/apps/sim/app/(auth)/components/auth-nav-prompt.tsx b/apps/sim/app/(auth)/components/auth-nav-prompt.tsx index d479a273b4a..bdea32fa50f 100644 --- a/apps/sim/app/(auth)/components/auth-nav-prompt.tsx +++ b/apps/sim/app/(auth)/components/auth-nav-prompt.tsx @@ -19,7 +19,7 @@ export function AuthNavPrompt({ prompt, href, linkLabel, onNavigate }: AuthNavPr return (
{prompt && {prompt}} - + {linkLabel}
diff --git a/apps/sim/app/(auth)/components/auth-submit-button.tsx b/apps/sim/app/(auth)/components/auth-submit-button.tsx index 5d37ecb12ea..139bec31892 100644 --- a/apps/sim/app/(auth)/components/auth-submit-button.tsx +++ b/apps/sim/app/(auth)/components/auth-submit-button.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react' import { Chip, Loader } from '@sim/emcn' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' interface AuthSubmitButtonProps { children: ReactNode @@ -32,7 +31,8 @@ export function AuthSubmitButton({ onClick={onClick} disabled={disabled || loading} fullWidth - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' > {loading ? ( diff --git a/apps/sim/app/(auth)/components/constants.ts b/apps/sim/app/(auth)/components/constants.ts deleted file mode 100644 index beb515c525d..00000000000 --- a/apps/sim/app/(auth)/components/constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Auth and invite surfaces use a slightly taller control than the 30px chip - * default, matching the landing `HeroCta` field family (the landing's own - * auth-adjacent CTA renders taller fields than in-app chips). Applied as the - * single source of truth for every auth field and button height so the inputs, - * submit, social, SSO, and invite action buttons stay on one line. - */ -export const AUTH_CONTROL_HEIGHT = 'h-9' - -/** - * Shared layout for full-width auth/invite chip buttons (submit, social, SSO, - * invite actions). `[&>span]:flex-none` collapses the chip's stretching label - * span — which carries `flex-1` — so the icon + label cluster truly centers - * under `justify-center` (the landing `HeroCta` idiom). Height-only inputs use - * {@link AUTH_CONTROL_HEIGHT}; buttons compose this on top of it. - */ -export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} justify-center [&>span]:flex-none` diff --git a/apps/sim/app/(auth)/components/password-input.tsx b/apps/sim/app/(auth)/components/password-input.tsx index f62e9c23ad3..e1c4867f24e 100644 --- a/apps/sim/app/(auth)/components/password-input.tsx +++ b/apps/sim/app/(auth)/components/password-input.tsx @@ -1,11 +1,10 @@ 'use client' import { useState } from 'react' -import { ChipInput, type ChipInputProps, cn } from '@sim/emcn' +import { ChipInput, type ChipInputProps } from '@sim/emcn' import { Eye, EyeOff } from '@sim/emcn/icons' -import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants' -type PasswordInputProps = Omit +type PasswordInputProps = Omit /** * A {@link ChipInput} that owns the password reveal toggle — the eye button is @@ -19,7 +18,8 @@ export function PasswordInput({ error, className, ...props }: PasswordInputProps return ( @@ -85,7 +86,9 @@ export function SocialLoginButtons({ @@ -97,7 +100,9 @@ export function SocialLoginButtons({ diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index 1bbd06d5591..03ac768b86f 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -1,8 +1,7 @@ 'use client' -import { Chip, cn } from '@sim/emcn' +import { Chip } from '@sim/emcn' import { useRouter } from 'next/navigation' import { isSsoEnabled } from '@/lib/core/config/env-flags' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' interface SSOLoginButtonProps { callbackURL?: string @@ -28,14 +27,12 @@ export function SSOLoginButton({ return ( Sign in with SSO diff --git a/apps/sim/app/(auth)/oauth/consent/consent-view.tsx b/apps/sim/app/(auth)/oauth/consent/consent-view.tsx index b083ef34f92..460cecf107d 100644 --- a/apps/sim/app/(auth)/oauth/consent/consent-view.tsx +++ b/apps/sim/app/(auth)/oauth/consent/consent-view.tsx @@ -14,7 +14,6 @@ import { AuthSubmitButton, AuthTextLink, } from '@/app/(auth)/components' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' import { OAuthConsentLoading } from '@/app/(auth)/oauth/consent/loading' import { useOAuthConsent, @@ -166,7 +165,8 @@ export function OAuthConsentView({ variant='border' fullWidth disabled={isPending} - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' onClick={() => decide(false)} > {consent.isPending && consent.variables === false ? 'Declining…' : 'Deny'} diff --git a/apps/sim/app/(auth)/verify/otp-error-source.test.tsx b/apps/sim/app/(auth)/verify/otp-error-source.test.tsx new file mode 100644 index 00000000000..f81caafe77f --- /dev/null +++ b/apps/sim/app/(auth)/verify/otp-error-source.test.tsx @@ -0,0 +1,174 @@ +/** @vitest-environment jsdom */ +import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + chatRequest: vi.fn(), + chatVerify: vi.fn(), + fileRequest: vi.fn(), + fileVerify: vi.fn(), + refresh: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), + ChipInput: ({ + error: _error, + size: _size, + ...props + }: Omit, 'size'> & { + error?: boolean + size?: string + }) => , + Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => ( + + ), + InputOTP: ({ + children, + value, + onChange, + 'aria-invalid': invalid, + }: { + children: ReactNode + value: string + onChange: (value: string) => void + 'aria-invalid'?: boolean + }) => ( +
+ onChange(event.target.value)} + /> + {children} +
+ ), + InputOTPGroup: ({ children }: { children: ReactNode }) =>
{children}
, + InputOTPSlot: ({ invalid, index }: { invalid?: boolean; index: number }) => ( + + ), +})) +vi.mock('@/lib/messaging/email/validation', () => ({ + quickValidateEmail: () => ({ isValid: true }), +})) +vi.mock('@/app/(auth)/components', () => ({ + AuthSubmitButton: ({ + children, + loading: _loading, + loadingLabel: _loadingLabel, + ...props + }: ButtonHTMLAttributes & { + loading?: boolean + loadingLabel?: string + }) => ( + + ), +})) +vi.mock('@/app/(auth)/components/auth-button-classes', () => ({ AUTH_TEXT_LINK: '' })) +vi.mock('@/components/auth/public-auth-header', () => ({ + PublicAuthHeader: ({ title }: { title: string }) =>

{title}

, +})) +vi.mock('@/app/f/[token]/public-file-auth-shell', () => ({ + PublicFileAuthShell: ({ children }: { children: ReactNode }) =>
{children}
, +})) +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) })) +vi.mock('@/hooks/queries/chats', () => ({ + useChatEmailOtpRequest: () => ({ mutateAsync: mocks.chatRequest, isPending: false }), + useChatEmailOtpVerify: () => ({ mutateAsync: mocks.chatVerify, isPending: false }), +})) +vi.mock('@/hooks/queries/public-shares', () => ({ + usePublicFileOtpRequest: () => ({ mutateAsync: mocks.fileRequest, isPending: false }), + usePublicFileOtpVerify: () => ({ mutateAsync: mocks.fileVerify, isPending: false }), +})) + +import EmailAuth from '@/app/(interfaces)/chat/components/auth/email/email-auth' +import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' + +let root: Root +let container: HTMLDivElement + +function changeInput(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function button(label: string) { + const found = Array.from(container.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === label + ) + if (!found) throw new Error(`Missing button: ${label}`) + return found +} + +function expectOtpInvalid(invalid: boolean) { + expect(container.querySelector('[data-testid="otp-code"]')?.getAttribute('aria-invalid')).toBe( + String(invalid) + ) + const slots = container.querySelectorAll('[data-otp-slot]') + expect(slots).toHaveLength(6) + for (const slot of slots) expect(slot.getAttribute('data-invalid')).toBe(String(invalid)) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.chatRequest.mockResolvedValue({}) + mocks.chatVerify.mockResolvedValue({}) + mocks.fileRequest.mockResolvedValue({}) + mocks.fileVerify.mockResolvedValue({}) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('OTP error provenance', () => { + it('keeps the chat code valid on resend failure and marks only a failed verification invalid', async () => { + act(() => root.render()) + act(() => + changeInput(container.querySelector('#email')!, 'member@example.com') + ) + await act(async () => button('Continue').click()) + + mocks.chatRequest.mockRejectedValueOnce(new Error('Delivery failed')) + await act(async () => button('Resend').click()) + expect(container.textContent).toContain('Delivery failed') + expectOtpInvalid(false) + + mocks.chatVerify.mockRejectedValueOnce(new Error('Incorrect code')) + await act(async () => + changeInput(container.querySelector('[data-testid="otp-code"]')!, '123456') + ) + expect(container.textContent).toContain('Incorrect code') + expectOtpInvalid(true) + }) + + it('keeps the public-file code valid on resend failure and marks only a failed verification invalid', async () => { + act(() => root.render()) + act(() => + changeInput(container.querySelector('#email')!, 'member@example.com') + ) + await act(async () => button('Continue').click()) + + mocks.fileRequest.mockRejectedValueOnce(new Error('Delivery failed')) + await act(async () => button('Resend').click()) + expect(container.textContent).toContain('Delivery failed') + expectOtpInvalid(false) + + mocks.fileVerify.mockRejectedValueOnce(new Error('Incorrect code')) + await act(async () => + changeInput(container.querySelector('[data-testid="otp-code"]')!, '123456') + ) + expect(container.textContent).toContain('Incorrect code') + expectOtpInvalid(true) + }) +}) diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 88d1c2e9d67..4e13de53044 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -1,7 +1,7 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' import { AuthFormMessage, @@ -84,14 +84,16 @@ function VerificationForm({

- + {OTP_SLOTS.map((index) => ( - + ))} diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx index 248d24644ee..190dfbaf917 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { ChipInput, cn, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { PublicAuthHeader } from '@/components/auth/public-auth-header' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { AuthSubmitButton } from '@/app/(auth)/components' import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes' @@ -33,13 +34,17 @@ const validateEmailField = (emailValue: string): string[] => { export default function EmailAuth({ identifier }: EmailAuthProps) { const [email, setEmail] = useState('') - const [authError, setAuthError] = useState(null) + const [authError, setAuthError] = useState<{ + kind: 'verification' | 'request' + message: string + } | null>(null) const [emailErrors, setEmailErrors] = useState([]) const hasEmailError = emailErrors.length > 0 const [showOtpVerification, setShowOtpVerification] = useState(false) const [otpValue, setOtpValue] = useState('') const [countdown, setCountdown] = useState(0) + const isInvalidOtp = authError?.kind === 'verification' const requestOtp = useChatEmailOtpRequest(identifier) const verifyOtp = useChatEmailOtpVerify(identifier) @@ -88,7 +93,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { await verifyOtp.mutateAsync({ email, otp: codeToVerify }) } catch (error) { logger.error('Error verifying OTP:', error) - setAuthError(toError(error).message || 'Invalid verification code') + setAuthError({ + kind: 'verification', + message: toError(error).message || 'Invalid verification code', + }) } } @@ -101,7 +109,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { setOtpValue('') } catch (error) { logger.error('Error resending OTP:', error) - setAuthError(toError(error).message || 'Failed to resend verification code') + setAuthError({ + kind: 'request', + message: toError(error).message || 'Failed to resend verification code', + }) setCountdown(0) } } @@ -110,16 +121,14 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
-
-

- {showOtpVerification ? 'Verify Your Email' : 'Email Verification'} -

-

- {showOtpVerification + -

+ : 'This chat requires email verification' + } + />
{!showOtpVerification ? ( @@ -144,7 +153,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { autoCorrect='off' value={email} onChange={handleEmailChange} - className='h-[34px]' + size='lg' error={Boolean(hasEmailError)} /> {hasEmailError && ( @@ -182,15 +191,12 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { } }} disabled={verifyOtp.isPending} - className={cn('gap-2', authError && 'otp-error')} + className={cn('gap-2', isInvalidOtp && 'otp-error')} + aria-invalid={isInvalidOtp} > {[0, 1, 2, 3, 4, 5].map((index) => ( - + ))} @@ -198,7 +204,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { {authError && (
-

{authError}

+

{authError.message}

)} @@ -230,7 +236,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {

-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index fdd9ed5c7ee..6f39702465d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1095,7 +1095,9 @@ function MessageContentInner({ <>
- Stopped by user + + Stopped by user +
{actions &&
{actionsRow}
} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 5b6560b4eae..78753a4ae69 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,7 +1,15 @@ 'use client' import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { OverflowText, PlayOutline, Skeleton, TabStripAction, Tooltip, toast } from '@sim/emcn' +import { + OverflowText, + PlayOutline, + ResourceEmptyState, + Skeleton, + TabStripAction, + Tooltip, + toast, +} from '@sim/emcn' import { Download, FileX, @@ -666,15 +674,11 @@ function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { if (!workflowExists || hasLoadError) { return ( -
- -
-

Workflow not found

-

- This workflow may have been deleted or moved -

-
-
+ ) } @@ -729,15 +733,11 @@ function EmbeddedFile({ if (!file) { return ( -
- -
-

File not found

-

- This file may have been deleted or moved -

-
-
+ ) } @@ -780,21 +780,17 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { if (!folder) { return ( -
- -
-

Folder not found

-

- This folder may have been deleted or moved -

-
-
+ ) } return (
-

{folder.name}

+

{folder.name}

{folderWorkflows.length === 0 ? (

No workflows in this folder

) : ( @@ -838,15 +834,11 @@ function EmbeddedLog({ workspaceId, logId, onNotFound }: EmbeddedLogProps) { if (!log) { return ( -
- -
-

Log not found

-

- This log may have been deleted or is no longer available -

-
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 088064324b1..32cfd395b83 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -59,12 +59,12 @@ export interface PlusMenuHandle { * Box and typography shared by the textarea and its mirror overlay — both must * produce identical line wrapping so the overlay text sits exactly over the * (transparent) textarea text. The scale is the chat input's native prompt - * scale (`text-[14px]`, `-0.015em` tracking); the task modal's body inherits it + * scale (`text-sm`, 14px at the default root, `-0.015em` tracking); the task modal's body inherits it * so the editor reads the same whether it's the chat input or inside the modal. */ const FIELD_MIRROR_CLASSES = cn( 'm-0 box-border min-h-[24px] w-full [overflow-wrap:anywhere] border-0 bg-transparent', - 'px-1 py-1 font-body text-[14px] leading-[24px] tracking-[-0.015em]' + 'px-1 py-1 font-body text-sm leading-[24px] tracking-[-0.015em]' ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx index d807f00a5be..67359e00939 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx @@ -8,10 +8,10 @@ import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/type import { getIntegrationMatcher } from '@/blocks/integration-matcher' const USER_MESSAGE_CLASSES = - 'whitespace-pre-wrap [overflow-wrap:anywhere] font-[family-name:var(--font-inter)] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased' + 'whitespace-pre-wrap [overflow-wrap:anywhere] font-inter text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased' const COMPACT_CLASSES = - 'truncate text-small leading-[20px] font-[family-name:var(--font-inter)] text-[var(--text-primary)] tracking-[0] antialiased' + 'truncate text-small leading-[20px] font-inter text-[var(--text-primary)] tracking-[0] antialiased' interface UserMessageContentProps { content: string diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index be6316624d4..cc387d5367e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -675,7 +675,7 @@ function HomeContent({ chatId, userName, userId }: HomeProps) {
{/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */}
-

+

What should we get done{firstName ? `, ${firstName}` : ''}?

diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index eb262824d69..b3202525bda 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -1,7 +1,14 @@ 'use client' import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' -import { Badge, ChipCombobox, ChipConfirmModal, chipContentLabelClass, cn } from '@sim/emcn' +import { + Badge, + ChipCombobox, + ChipConfirmModal, + chipContentLabelClass, + cn, + ResourceEmptyState, +} from '@sim/emcn' import { ChevronDown, ChevronUp, @@ -38,7 +45,6 @@ import type { import { EMPTY_CELL_PLACEHOLDER, Resource, - ResourceNotFound, SearchHighlight, } from '@/app/workspace/[workspaceId]/components' import { @@ -1163,7 +1169,7 @@ export function Document({ */ if (documentError && !documentData) { return ( - - ) - } - return ( - - {name.charAt(0).toUpperCase()} - + + {image && } + {name.charAt(0).toUpperCase()} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index 53bd21ddf25..0772217a108 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -322,7 +322,7 @@ const FilterRuleRow = memo(function FilterRuleRow({ ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index f040e922466..77e41daefa5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { cn, toast, useToast } from '@sim/emcn' +import { cn, ResourceEmptyState, toast, useToast } from '@sim/emcn' import { Loader, TableX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import type { TableCellSelection } from '@sim/realtime-protocol/table-presence' @@ -4695,15 +4695,11 @@ export function TableGrid({ if (!isLoadingTable && !tableData) { return ( -
- -
-

Table not found

-

- This table may have been deleted or moved -

-
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx index c14dfdeeda4..90e13df381c 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx @@ -78,13 +78,15 @@ export function UpgradePlanCard({ >
-

{name}

+

{name}

{bannerText && {bannerText}}
- {price} + + {price} + {discountLabel && {discountLabel}}

{priceSubtext ?? '\u00A0'}

diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 7ec5cadb581..a69dd3eea4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -192,7 +192,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
-

+

{header}

{state.showUpgradePlans && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index cb6fb366763..e951f69a076 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -130,7 +130,7 @@ function ChatFilePreview({ file, onRemove }: ChatFilePreviewProps) { ) : (
{file.name}
-
{formatFileSize(file.size)}
+
{formatFileSize(file.size)}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx index 3d2e7e8b88a..f8cc36b1ef6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx @@ -208,7 +208,7 @@ export function CommandList() { {/* Right side: Keyboard Shortcut */}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index b26c5ac8ccd..f9f144e7c46 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -112,6 +112,23 @@ function copilotDraftKey( ): string | undefined { return workflowId ? `${workspaceId}:workflow-copilot:${workflowId}:${chatId ?? 'new'}` : undefined } + +interface PanelTabChipProps { + tab: PanelTab + active: boolean + onClick: () => void + children: string +} + +function PanelTabChip({ tab, active, onClick, children }: PanelTabChipProps) { + return ( + + + {children} + + + ) +} /** * Panel component with resizable width and tab navigation that persists across page refreshes. * @@ -853,43 +870,28 @@ export const Panel = memo(function Panel() {
{isCopilotTabAvailable && ( - + )} - - +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index 7eb787be542..a827375fb42 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, + Chip, Code, cn, Input, @@ -308,29 +309,23 @@ export const OutputPanel = React.memo(function OutputPanel({ onClick={handleHeaderClick} >
- + {hasInputData && ( - + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index c7c6880bf90..c076b48eda3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -435,7 +435,7 @@ function ConnectionsSection({ } >
- V + V
- E + E
{!isNoteBlock && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx index 1da14a9bf22..8896f96f435 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx @@ -155,7 +155,7 @@ export function SidebarFooter({ /> ) : (
{name.charAt(0).toUpperCase()} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 02d119714c3..8ef6f8ef7ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -1560,7 +1560,7 @@ export const Sidebar = memo(function Sidebar() { } onKeyDown={chatFlyoutRename.handleKeyDown} onBlur={handleChatRenameBlur} - className='min-w-0 flex-1 border-none bg-transparent text-[14px] text-[var(--text-body)] outline-hidden' + className='min-w-0 flex-1 border-none bg-transparent text-[var(--text-body)] text-sm leading-[inherit] outline-hidden' />
) diff --git a/apps/sim/components/auth/public-auth-header.tsx b/apps/sim/components/auth/public-auth-header.tsx new file mode 100644 index 00000000000..31c151205a9 --- /dev/null +++ b/apps/sim/components/auth/public-auth-header.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' + +interface PublicAuthHeaderProps { + title: ReactNode + description: ReactNode +} + +/** Heading shared by public chat, file-share and SSO access gates. */ +export function PublicAuthHeader({ title, description }: PublicAuthHeaderProps) { + return ( +
+

+ {title} +

+

+ {description} +

+
+ ) +} diff --git a/apps/sim/components/emails/_styles/base.tokens.test.ts b/apps/sim/components/emails/_styles/base.tokens.test.ts index 2c268e7756c..088c91aa91c 100644 --- a/apps/sim/components/emails/_styles/base.tokens.test.ts +++ b/apps/sim/components/emails/_styles/base.tokens.test.ts @@ -79,9 +79,9 @@ describe('email type scale mirrors the globals.css @theme scale', () => { ) }) - it('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => { + it('sm adopts Tailwind stock 0.875rem, matching 14px email text at the default root', () => { expect(typography.fontSize.sm).toBe('14px') - expect(tailwindFontSize.sm).toBeUndefined() + expect(tailwindFontSize.sm).toBe('0.875rem') expect(chipGeometryClass).toContain('text-sm') }) diff --git a/apps/sim/components/ui/thinking-loader.module.css b/apps/sim/components/ui/thinking-loader.module.css index 52703157b5e..8a884235143 100644 --- a/apps/sim/components/ui/thinking-loader.module.css +++ b/apps/sim/components/ui/thinking-loader.module.css @@ -85,14 +85,14 @@ is 400, never medium. */ .label { composes: shimmer from "./shimmer-text.module.css"; - font-size: var(--tl-label-size, 14px); - font-weight: 400; + font-size: var(--tl-label-size, var(--text-sm)); + font-weight: var(--font-weight-normal); } /* Static label (shimmer off): the phrase in solid body ink, no gradient sweep. */ .labelStatic { - font-size: var(--tl-label-size, 14px); - font-weight: 400; + font-size: var(--tl-label-size, var(--text-sm)); + font-weight: var(--font-weight-normal); color: var(--text-body); } diff --git a/apps/sim/ee/sso/components/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx index e5489919698..0b8deb58c9e 100644 --- a/apps/sim/ee/sso/components/sso-auth.tsx +++ b/apps/sim/ee/sso/components/sso-auth.tsx @@ -4,6 +4,7 @@ import { type KeyboardEvent, useState } from 'react' import { ChipInput, Label } from '@sim/emcn' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' +import { PublicAuthHeader } from '@/components/auth/public-auth-header' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { chatSSOContract } from '@/lib/api/contracts/chats' @@ -98,14 +99,10 @@ export default function SSOAuth({ identifier }: SSOAuthProps) {
-
-

- SSO Authentication -

-

- This chat requires SSO authentication -

-
+ { @@ -130,7 +127,7 @@ export default function SSOAuth({ identifier }: SSOAuthProps) { value={email} onChange={handleEmailChange} onKeyDown={handleKeyDown} - className='h-[34px]' + size='lg' error={showEmailValidationError && emailErrors.length > 0} /> {showEmailValidationError && emailErrors.length > 0 && ( diff --git a/apps/sim/ee/sso/components/sso-form.test.tsx b/apps/sim/ee/sso/components/sso-form.test.tsx index 369ef6a165b..9f71416d717 100644 --- a/apps/sim/ee/sso/components/sso-form.test.tsx +++ b/apps/sim/ee/sso/components/sso-form.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react' +import { act, type InputHTMLAttributes, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,15 +26,16 @@ vi.mock('next/link', () => ({ })) vi.mock('@sim/emcn', () => ({ - Button: ({ children, ...props }: ButtonHTMLAttributes) => ( - + ChipLink: ({ href, children }: { href: string; children?: ReactNode }) => ( + {children} ), ChipInput: ({ error: _error, + size: _size, ...props - }: InputHTMLAttributes & { error?: boolean }) => , + }: Omit, 'size'> & { error?: boolean; size?: string }) => ( + + ), Label: ({ children }: { children?: ReactNode }) => {children}, cn: (...values: unknown[]) => values.filter(Boolean).join(' '), })) diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index 494a9d25df7..369de48c16d 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -1,10 +1,11 @@ 'use client' import { useEffect, useState } from 'react' -import { Button, ChipInput, Label } from '@sim/emcn' +import { ChipInput, ChipLink, Label } from '@sim/emcn' import { createLogger } from '@sim/logger' import Link from 'next/link' import { useSearchParams } from 'next/navigation' +import { PublicAuthHeader } from '@/components/auth/public-auth-header' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { resolveSsoProviderContract } from '@/lib/api/contracts/auth' @@ -186,22 +187,7 @@ function SSOFormContent({ return ( <> -
-

- Sign in with SSO -

-

- Enter your work email to continue -

-
+ {formError && ( @@ -227,7 +213,7 @@ function SSOFormContent({ onChange={handleEmailChange} aria-invalid={hasEmailError || undefined} aria-describedby={hasEmailError ? 'sso-email-errors' : undefined} - className='h-[34px]' + size='lg' error={Boolean(hasEmailError)} /> {hasEmailError && ( @@ -251,7 +237,7 @@ function SSOFormContent({ {emailEnabled && ( <> -
+
@@ -261,19 +247,21 @@ function SSOFormContent({
- - - + Sign in with email +
)} {emailEnabled && !registrationDisabled && ( -
+
Don't have an account? { + act(() => root?.unmount()) + container?.remove() +}) + +describe('Avatar fallback sizing', () => { + it('scopes xs sizing to its avatar while preserving explicit overrides', () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => + root?.render( + <> + + A + + + BC + + + + D + + + + ) + ) + const xs = container.querySelector('[data-testid="xs"]')! + const regular = container.querySelector('[data-testid="default"]')! + const overridden = container.querySelector('[data-testid="override"]')! + expect(xs.classList.contains('text-[8px]')).toBe(true) + expect(xs.classList.contains('text-xs')).toBe(false) + expect(regular.classList.contains('text-xs')).toBe(true) + expect(regular.classList.contains('text-[8px]')).toBe(false) + expect(overridden.classList.contains('text-[7px]')).toBe(true) + expect(overridden.classList.contains('text-[8px]')).toBe(false) + expect(overridden.classList.contains('text-xs')).toBe(false) + }) +}) diff --git a/packages/emcn/src/components/avatar/avatar.tsx b/packages/emcn/src/components/avatar/avatar.tsx index 85ebaa522c8..f2dc07acc56 100644 --- a/packages/emcn/src/components/avatar/avatar.tsx +++ b/packages/emcn/src/components/avatar/avatar.tsx @@ -50,6 +50,8 @@ const avatarStatusVariants = cva( } ) +const AvatarSizeContext = React.createContext['size']>('md') + type AvatarStatus = 'online' | 'offline' | 'busy' | 'away' interface AvatarProps @@ -93,22 +95,24 @@ interface AvatarProps */ const Avatar = React.forwardRef, AvatarProps>( ({ className, size, status, children, ...props }, ref) => ( -
- - {children} - - {status && ( - - )} -
+ +
+ + {children} + + {status && ( + + )} +
+
) ) Avatar.displayName = 'Avatar' @@ -133,6 +137,7 @@ AvatarImage.displayName = 'AvatarImage' /** * Fallback component for Avatar. Displays initials or icon when image is unavailable. + * The xs size uses 8px initials; other sizes retain text-xs. * * Carries the package's only hardcoded `font-medium`, and deliberately: one or * two capitals at `text-xs` on a filled disc are a glyph, not running text, and @@ -142,16 +147,20 @@ AvatarImage.displayName = 'AvatarImage' const AvatarFallback = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) +>(({ className, ...props }, ref) => { + const size = React.useContext(AvatarSizeContext) + return ( + + ) +}) AvatarFallback.displayName = 'AvatarFallback' export { Avatar, AvatarImage, AvatarFallback, avatarVariants, avatarStatusVariants } diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index a1d88b15c9c..c97f51b6784 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -162,7 +162,13 @@ export { type InfoCardProps, } from './info-card/info-card' export { Input, type InputProps } from './input/input' -export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp' +export { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, + type InputOTPSlotProps, +} from './input-otp/input-otp' export { Label } from './label/label' export { Lightbox, type LightboxProps } from './lightbox/lightbox' export { focusFirstTextInput, focusFirstTextInputIn } from './modal/auto-focus' @@ -210,6 +216,10 @@ export { } from './popover/popover' export { POPOVER_ANIMATION_CLASSES } from './popover/popover-animation' export { ProgressItem } from './progress-item/progress-item' +export { + ResourceEmptyState, + type ResourceEmptyStateProps, +} from './resource-empty-state/resource-empty-state' export { SecretInput } from './secret-input/secret-input' export { SecretReveal } from './secret-reveal/secret-reveal' export { WORDMARK_PATHS, WORDMARK_VIEW_BOX } from './sim-wordmark/paths' diff --git a/packages/emcn/src/components/input-otp/input-otp.test.tsx b/packages/emcn/src/components/input-otp/input-otp.test.tsx new file mode 100644 index 00000000000..9bbb8dbd699 --- /dev/null +++ b/packages/emcn/src/components/input-otp/input-otp.test.tsx @@ -0,0 +1,66 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { InputOTP, InputOTPGroup, InputOTPSlot } from './input-otp' + +let root: Root +let host: HTMLDivElement + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.unstubAllGlobals() +}) + +function renderOtp(invalid: boolean) { + act(() => { + root.render( + + + + + + + ) + }) +} + +describe('InputOTPSlot invalid state', () => { + it('keeps the standard border when valid', () => { + renderOtp(false) + const slot = host.querySelector('[data-testid="first-slot"]') + expect(slot?.classList.contains('border-[var(--border-1)]')).toBe(true) + expect(slot?.classList.contains('border-[var(--text-error)]')).toBe(false) + expect(host.querySelector('input')?.getAttribute('aria-invalid')).toBe('false') + }) + + it('keeps the error border while the active slot has a focus ring', () => { + renderOtp(true) + const input = host.querySelector('input') + expect(input?.getAttribute('aria-invalid')).toBe('true') + + act(() => input?.focus()) + const slot = host.querySelector('[data-testid="first-slot"]') + expect(slot?.classList.contains('border-[var(--text-error)]')).toBe(true) + expect(slot?.classList.contains('ring-1')).toBe(true) + expect(slot?.classList.contains('border-[var(--text-muted)]')).toBe(false) + }) +}) diff --git a/packages/emcn/src/components/input-otp/input-otp.tsx b/packages/emcn/src/components/input-otp/input-otp.tsx index a6c5ea82fff..2c3ff3a83b7 100644 --- a/packages/emcn/src/components/input-otp/input-otp.tsx +++ b/packages/emcn/src/components/input-otp/input-otp.tsx @@ -66,32 +66,39 @@ InputOTPGroup.displayName = 'InputOTPGroup' * * Uses emcn design tokens for consistent styling with the Input component. */ -const InputOTPSlot = React.forwardRef< - React.ElementRef<'div'>, - React.ComponentPropsWithoutRef<'div'> & { index: number } ->(({ index, className, ...props }, ref) => { - const inputOTPContext = React.useContext(OTPInputContext) - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] +export interface InputOTPSlotProps extends React.ComponentPropsWithoutRef<'div'> { + /** Zero-based position of this slot in the OTP input. */ + index: number + /** Keep the error border visible even while this slot is active. */ + invalid?: boolean +} + +const InputOTPSlot = React.forwardRef, InputOTPSlotProps>( + ({ index, invalid = false, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] - return ( -
- {char} - {hasFakeCaret && ( -
-
-
- )} -
- ) -}) + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) + } +) InputOTPSlot.displayName = 'InputOTPSlot' /** diff --git a/packages/emcn/src/components/resource-empty-state/resource-empty-state.tsx b/packages/emcn/src/components/resource-empty-state/resource-empty-state.tsx new file mode 100644 index 00000000000..af181c7e890 --- /dev/null +++ b/packages/emcn/src/components/resource-empty-state/resource-empty-state.tsx @@ -0,0 +1,24 @@ +import type { ComponentType } from 'react' + +export interface ResourceEmptyStateProps { + icon: ComponentType<{ className?: string }> + title: string + description: string +} + +/** + * Centered resource-empty state for a page or panel. The caller owns loading and + * access decisions; this component only presents the unavailable-resource message. + * @example + */ +export function ResourceEmptyState({ icon: Icon, title, description }: ResourceEmptyStateProps) { + return ( +
+ +
+

{title}

+

{description}

+
+
+ ) +} diff --git a/packages/emcn/src/lib/cn.test.ts b/packages/emcn/src/lib/cn.test.ts index 7507ea397f4..b87665583aa 100644 --- a/packages/emcn/src/lib/cn.test.ts +++ b/packages/emcn/src/lib/cn.test.ts @@ -25,13 +25,22 @@ describe('cn', () => { ['text-base text-md', 'text-md'], ['text-md text-base', 'text-base'], ['text-small text-md text-micro', 'text-micro'], + ['text-sm text-title', 'text-title'], + ['text-title-lg text-xl', 'text-xl'], + ['text-title text-title-lg text-display', 'text-display'], ])('%s -> %s', (input, expected) => { expect(cn(input)).toBe(expected) }) - it('keeps a font size and a text colour together', () => { - expect(cn('text-small text-[var(--text-body)]')).toBe('text-small text-[var(--text-body)]') - }) + it.each(['small', 'title', 'title-lg', 'display'])( + 'keeps text-%s and a text colour together in either order', + (size) => { + const fontSize = `text-${size}` + const colour = 'text-[var(--text-body)]' + expect(cn(fontSize, colour)).toBe(`${fontSize} ${colour}`) + expect(cn(colour, fontSize)).toBe(`${colour} ${fontSize}`) + } + ) it('conflicts an arbitrary size with a named one', () => { expect(cn('text-[13px] text-small')).toBe('text-small') diff --git a/packages/emcn/src/lib/cn.ts b/packages/emcn/src/lib/cn.ts index 44b68c24039..63f87a80c53 100644 --- a/packages/emcn/src/lib/cn.ts +++ b/packages/emcn/src/lib/cn.ts @@ -14,7 +14,7 @@ import { extendTailwindMerge } from 'tailwind-merge' const twMerge = extendTailwindMerge({ extend: { classGroups: { - 'font-size': [{ text: ['micro', 'caption', 'small', 'md'] }], + 'font-size': [{ text: ['micro', 'caption', 'small', 'md', 'title', 'title-lg', 'display'] }], }, }, }) diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx index 6b71def6fb5..b3cfd5845dc 100644 --- a/packages/workflow-renderer/src/note/note-block-view.tsx +++ b/packages/workflow-renderer/src/note/note-block-view.tsx @@ -1017,7 +1017,7 @@ export function NoteBlockView({ } }} className={cn( - 'nodrag nopan nowheel h-7 w-full min-w-0 select-text border-none bg-transparent px-0 text-[17px] text-current caret-current outline-hidden focus-visible:outline-hidden', + 'nodrag nopan nowheel h-7 w-full min-w-0 select-text border-none bg-transparent px-0 text-current text-md caret-current outline-hidden focus-visible:outline-hidden', colorOption.selectionClassName, !isEnabled && 'opacity-50' )} @@ -1033,14 +1033,14 @@ export function NoteBlockView({ !isEnabled && 'opacity-50' )} > - + {renderMarkedName(name ?? '', nameSearchRange)} ) : ( {renderMarkedName(name ?? '', nameSearchRange)} diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index 4886960c4fb..8fe376b8a8e 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -620,7 +620,7 @@ export function SubflowNodeView({ >
diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index cae87070f2f..8cd8d87a2ce 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -772,7 +772,7 @@ export function WorkflowBlockView({ diff --git a/packages/workflow-renderer/src/workflow-type.tsx b/packages/workflow-renderer/src/workflow-type.tsx index 22ed75db83e..80f8f11b9c9 100644 --- a/packages/workflow-renderer/src/workflow-type.tsx +++ b/packages/workflow-renderer/src/workflow-type.tsx @@ -169,7 +169,7 @@ export function BlockTileView({ /> ) : ( fallbackLabel && ( - {fallbackLabel} + {fallbackLabel} ) )}