UNPKG

analytica-frontend-lib

Version:

Repositório público dos componentes utilizados nas plataformas da Analytica Ensino

1 lines 411 kB
{"version":3,"sources":["../../src/components/SendActivityModal/SendActivityModal.tsx","../../src/components/Modal/Modal.tsx","../../src/utils/utils.ts","../../src/components/Button/Button.tsx","../../src/components/Modal/utils/videoUtils.ts","../../src/components/Text/Text.tsx","../../src/components/Stepper/Stepper.tsx","../../src/components/Chips/Chips.tsx","../../src/components/Input/Input.tsx","../../src/components/TextArea/TextArea.tsx","../../src/components/Radio/Radio.tsx","../../src/components/CheckBoxGroup/CheckBoxGroup.tsx","../../src/components/Badge/Badge.tsx","../../src/components/SelectionButton/SelectionButton.tsx","../../src/components/CheckBox/CheckBox.tsx","../../src/components/Divider/Divider.tsx","../../src/components/DropdownMenu/DropdownMenu.tsx","../../src/components/ThemeToggle/ThemeToggle.tsx","../../src/hooks/useTheme.ts","../../src/store/themeStore.ts","../../src/components/ProgressBar/ProgressBar.tsx","../../src/components/Calendar/Calendar.tsx","../../src/components/DateTimeInput/DateTimeInput.tsx","../../src/components/Accordation/Accordation.tsx","../../src/components/Card/Card.tsx","../../src/components/IconRender/IconRender.tsx","../../src/assets/icons/subjects/ChatPT.tsx","../../src/assets/icons/subjects/ChatEN.tsx","../../src/assets/icons/subjects/ChatES.tsx","../../src/components/Accordation/AccordionGroup.tsx","../../src/components/SendActivityModal/hooks/useSendActivityModal.ts","../../src/components/SendActivityModal/validation.ts","../../src/components/SendActivityModal/types.ts","../../src/components/CheckBoxGroup/CheckBoxGroup.helpers.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, ChangeEvent } from 'react';\nimport {\n CaretLeftIcon,\n ArrowRightIcon,\n PaperPlaneTiltIcon,\n WarningCircleIcon,\n} from '@phosphor-icons/react';\nimport Modal from '../Modal/Modal';\nimport Stepper from '../Stepper/Stepper';\nimport Button from '../Button/Button';\nimport Chips from '../Chips/Chips';\nimport Input from '../Input/Input';\nimport TextArea from '../TextArea/TextArea';\nimport Text from '../Text/Text';\nimport { RadioGroup, RadioGroupItem } from '../Radio/Radio';\nimport { CheckboxGroup } from '../CheckBoxGroup/CheckBoxGroup';\nimport DateTimeInput from '../DateTimeInput/DateTimeInput';\nimport { useSendActivityModalStore } from './hooks/useSendActivityModal';\nimport {\n SendActivityModalProps,\n SendActivityModalInitialData,\n ActivitySubtype,\n SendActivityFormData,\n ACTIVITY_TYPE_OPTIONS,\n CategoryConfig,\n} from './types';\nimport { cn } from '../../utils/utils';\n\n/**\n * Stepper steps configuration\n */\nconst STEPPER_STEPS = [\n { id: 'activity', label: 'Atividade', state: 'pending' as const },\n { id: 'recipient', label: 'Destinatário', state: 'pending' as const },\n { id: 'deadline', label: 'Prazo', state: 'pending' as const },\n];\n\n/**\n * SendActivityModal component for sending activities to students\n *\n * A multi-step modal with 3 steps:\n * 1. Activity - Select type, title, and optional notification message\n * 2. Recipient - Select students from hierarchical structure using CheckboxGroup\n * 3. Deadline - Set start/end dates and retry option\n */\nconst SendActivityModal = ({\n isOpen,\n onClose,\n onSubmit,\n categories: initialCategories,\n onCategoriesChange,\n isLoading = false,\n onError,\n initialData,\n}: SendActivityModalProps) => {\n const store = useSendActivityModalStore();\n const reset = useSendActivityModalStore((state) => state.reset);\n const setCategories = useSendActivityModalStore(\n (state) => state.setCategories\n );\n const storeCategories = useSendActivityModalStore(\n (state) => state.categories\n );\n\n /**\n * Track if categories have been initialized for this modal session\n */\n const categoriesInitialized = useRef(false);\n\n /**\n * Track the previous initialData reference to detect changes\n */\n const prevInitialDataRef = useRef<SendActivityModalInitialData | undefined>(\n undefined\n );\n\n /**\n * Initialize categories when modal opens\n */\n useEffect(() => {\n if (\n isOpen &&\n initialCategories.length > 0 &&\n !categoriesInitialized.current\n ) {\n setCategories(initialCategories);\n categoriesInitialized.current = true;\n }\n }, [isOpen, initialCategories, setCategories]);\n\n /**\n * Apply initial data when modal opens with new data\n */\n useEffect(() => {\n if (isOpen && initialData && prevInitialDataRef.current !== initialData) {\n store.setFormData({\n title: initialData.title ?? '',\n subtype: initialData.subtype ?? 'TAREFA',\n notification: initialData.notification ?? '',\n });\n prevInitialDataRef.current = initialData;\n }\n }, [isOpen, initialData, store]);\n\n /**\n * Reset store and initialization flag when modal closes\n */\n useEffect(() => {\n if (!isOpen) {\n reset();\n categoriesInitialized.current = false;\n prevInitialDataRef.current = undefined;\n }\n }, [isOpen, reset]);\n\n /**\n * Handle categories change from CheckboxGroup\n */\n const handleCategoriesChange = useCallback(\n (updatedCategories: CategoryConfig[]) => {\n setCategories(updatedCategories);\n onCategoriesChange?.(updatedCategories);\n },\n [setCategories, onCategoriesChange]\n );\n\n /**\n * Handle activity type selection\n */\n const handleActivityTypeSelect = useCallback(\n (subtype: ActivitySubtype) => {\n store.setFormData({ subtype });\n },\n [store]\n );\n\n /**\n * Handle title change\n */\n const handleTitleChange = useCallback(\n (e: ChangeEvent<HTMLInputElement>) => {\n store.setFormData({ title: e.target.value });\n },\n [store]\n );\n\n /**\n * Handle notification message change\n */\n const handleNotificationChange = useCallback(\n (e: ChangeEvent<HTMLTextAreaElement>) => {\n store.setFormData({ notification: e.target.value });\n },\n [store]\n );\n\n /**\n * Handle start date change\n */\n const handleStartDateChange = useCallback(\n (date: string) => {\n store.setFormData({ startDate: date });\n },\n [store]\n );\n\n /**\n * Handle start time change\n */\n const handleStartTimeChange = useCallback(\n (time: string) => {\n store.setFormData({ startTime: time });\n },\n [store]\n );\n\n /**\n * Handle final date change\n */\n const handleFinalDateChange = useCallback(\n (date: string) => {\n store.setFormData({ finalDate: date });\n },\n [store]\n );\n\n /**\n * Handle final time change\n */\n const handleFinalTimeChange = useCallback(\n (time: string) => {\n store.setFormData({ finalTime: time });\n },\n [store]\n );\n\n /**\n * Handle retry option change\n */\n const handleRetryChange = useCallback(\n (value: string) => {\n store.setFormData({ canRetry: value === 'yes' });\n },\n [store]\n );\n\n /**\n * Handle form submission\n */\n const handleSubmit = useCallback(async () => {\n const isValid = store.validateAllSteps();\n if (!isValid) return;\n\n try {\n const formData = store.formData as SendActivityFormData;\n await onSubmit(formData);\n } catch (error) {\n if (onError) {\n onError(error);\n } else {\n console.error('Falha ao enviar atividade:', error);\n }\n }\n }, [store, onSubmit, onError]);\n\n /**\n * Handle cancel button click\n */\n const handleCancel = useCallback(() => {\n onClose();\n }, [onClose]);\n\n /**\n * Render error message helper\n */\n const renderError = (error?: string) => {\n if (!error) return null;\n return (\n <Text\n as=\"p\"\n size=\"sm\"\n color=\"text-error-600\"\n className=\"flex items-center gap-1 mt-1\"\n >\n <WarningCircleIcon size={16} />\n {error}\n </Text>\n );\n };\n\n /**\n * Render Step 1 - Activity\n */\n const renderActivityStep = () => (\n <div className=\"flex flex-col gap-6\">\n {/* Activity Type Selection */}\n <div>\n <Text size=\"sm\" weight=\"medium\" color=\"text-text-700\" className=\"mb-3\">\n Tipo de atividade*\n </Text>\n <div className=\"flex flex-wrap gap-2\">\n {ACTIVITY_TYPE_OPTIONS.map((type) => (\n <Chips\n key={type.value}\n selected={store.formData.subtype === type.value}\n onClick={() => handleActivityTypeSelect(type.value)}\n >\n {type.label}\n </Chips>\n ))}\n </div>\n {renderError(store.errors.subtype)}\n </div>\n\n {/* Title Input */}\n <Input\n label=\"Título\"\n placeholder=\"Digite o título da atividade\"\n value={store.formData.title || ''}\n onChange={handleTitleChange}\n variant=\"rounded\"\n required\n errorMessage={store.errors.title}\n />\n\n {/* Notification Message */}\n <TextArea\n label=\"Mensagem da notificação\"\n placeholder=\"Digite uma mensagem para a notificação (opcional)\"\n value={store.formData.notification || ''}\n onChange={handleNotificationChange}\n />\n </div>\n );\n\n /**\n * Render Step 2 - Recipient using CheckboxGroup\n */\n const renderRecipientStep = () => {\n // Use store categories if available, otherwise use initial categories\n const categoriesToRender =\n storeCategories.length > 0 ? storeCategories : initialCategories;\n\n return (\n <div className=\"flex flex-col gap-4\">\n <Text size=\"sm\" weight=\"medium\" color=\"text-text-700\">\n Para quem você vai enviar a atividade?\n </Text>\n\n <div\n className={cn(\n 'max-h-[300px] overflow-y-auto',\n 'scrollbar-thin scrollbar-thumb-border-300 scrollbar-track-transparent'\n )}\n >\n <CheckboxGroup\n categories={categoriesToRender}\n onCategoriesChange={handleCategoriesChange}\n compactSingleItem={true}\n showDivider={true}\n />\n </div>\n\n {renderError(store.errors.students)}\n </div>\n );\n };\n\n /**\n * Render Step 3 - Deadline\n */\n const renderDeadlineStep = () => (\n <div className=\"flex flex-col gap-4 sm:gap-6 pt-6\">\n {/* Date/Time Row - Side by Side */}\n <div className=\"grid grid-cols-2 gap-2\">\n <DateTimeInput\n label=\"Iniciar em*\"\n date={store.formData.startDate || ''}\n time={store.formData.startTime || ''}\n onDateChange={handleStartDateChange}\n onTimeChange={handleStartTimeChange}\n errorMessage={store.errors.startDate}\n defaultTime=\"00:00\"\n testId=\"start-datetime\"\n className=\"w-full\"\n />\n\n <DateTimeInput\n label=\"Finalizar até*\"\n date={store.formData.finalDate || ''}\n time={store.formData.finalTime || ''}\n onDateChange={handleFinalDateChange}\n onTimeChange={handleFinalTimeChange}\n errorMessage={store.errors.finalDate}\n defaultTime=\"23:59\"\n testId=\"final-datetime\"\n className=\"w-full\"\n />\n </div>\n\n {/* Retry Option */}\n <div>\n <Text size=\"sm\" weight=\"medium\" color=\"text-text-700\" className=\"mb-3\">\n Permitir refazer?\n </Text>\n <RadioGroup\n value={store.formData.canRetry ? 'yes' : 'no'}\n onValueChange={handleRetryChange}\n className=\"flex flex-row gap-6\"\n >\n <div className=\"flex items-center gap-2\">\n <RadioGroupItem value=\"yes\" id=\"radio-item-yes\" />\n <Text\n as=\"label\"\n size=\"sm\"\n color=\"text-text-700\"\n className=\"cursor-pointer\"\n htmlFor=\"radio-item-yes\"\n >\n Sim\n </Text>\n </div>\n <div className=\"flex items-center gap-2\">\n <RadioGroupItem value=\"no\" id=\"radio-item-no\" />\n <Text\n as=\"label\"\n size=\"sm\"\n color=\"text-text-700\"\n className=\"cursor-pointer\"\n htmlFor=\"radio-item-no\"\n >\n Não\n </Text>\n </div>\n </RadioGroup>\n </div>\n </div>\n );\n\n /**\n * Render current step content\n */\n const renderStepContent = () => {\n switch (store.currentStep) {\n case 1:\n return renderActivityStep();\n case 2:\n return renderRecipientStep();\n case 3:\n return renderDeadlineStep();\n default:\n return null;\n }\n };\n\n /**\n * Render footer buttons\n */\n const renderFooter = () => (\n <div className=\"flex flex-col-reverse sm:flex-row items-center justify-between gap-3 w-full\">\n <Button\n variant=\"link\"\n action=\"primary\"\n onClick={handleCancel}\n className=\"w-full sm:w-auto\"\n >\n Cancelar\n </Button>\n\n <div className=\"flex flex-col-reverse sm:flex-row items-center gap-2 sm:gap-3 w-full sm:w-auto\">\n {store.currentStep > 1 && (\n <Button\n variant=\"outline\"\n action=\"primary\"\n onClick={store.previousStep}\n iconLeft={<CaretLeftIcon size={16} />}\n className=\"w-full sm:w-auto\"\n >\n Anterior\n </Button>\n )}\n\n {store.currentStep < 3 ? (\n <Button\n variant=\"solid\"\n action=\"primary\"\n onClick={() => store.nextStep()}\n iconRight={<ArrowRightIcon size={16} />}\n className=\"w-full sm:w-auto\"\n >\n Próximo\n </Button>\n ) : (\n <Button\n variant=\"solid\"\n action=\"primary\"\n onClick={handleSubmit}\n disabled={isLoading}\n iconLeft={<PaperPlaneTiltIcon size={16} />}\n className=\"w-full sm:w-auto\"\n >\n {isLoading ? 'Enviando...' : 'Enviar atividade'}\n </Button>\n )}\n </div>\n </div>\n );\n\n return (\n <Modal\n isOpen={isOpen}\n onClose={onClose}\n title=\"Enviar atividade\"\n size=\"md\"\n footer={renderFooter()}\n contentClassName=\"flex flex-col gap-8 sm:gap-10 max-h-[70vh] overflow-y-auto\"\n >\n {/* Stepper */}\n <Stepper\n steps={STEPPER_STEPS}\n currentStep={store.currentStep - 1}\n size=\"small\"\n />\n\n {/* Step Content */}\n {renderStepContent()}\n </Modal>\n );\n};\n\nexport default SendActivityModal;\n","import { ReactNode, useEffect, useId } from 'react';\nimport { X } from 'phosphor-react';\nimport { cn } from '../../utils/utils';\nimport Button from '../Button/Button';\nimport {\n isYouTubeUrl,\n getYouTubeVideoId,\n getYouTubeEmbedUrl,\n} from './utils/videoUtils';\n\n/**\n * Lookup table for size classes\n */\nconst SIZE_CLASSES = {\n xs: 'max-w-[360px]',\n sm: 'max-w-[420px]',\n md: 'max-w-[510px]',\n lg: 'max-w-[640px]',\n xl: 'max-w-[970px]',\n} as const;\n\n/**\n * Modal component props interface\n */\ntype ModalProps = {\n contentClassName?: string;\n /** Whether the modal is open */\n isOpen: boolean;\n /** Function to close the modal */\n onClose: () => void;\n /** Modal title */\n title: string;\n /** Modal description/content */\n children?: ReactNode;\n /** Size of the modal */\n size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n /** Additional CSS classes for the modal content */\n className?: string;\n /** Whether pressing Escape should close the modal */\n closeOnEscape?: boolean;\n /** Footer content (typically buttons) */\n footer?: ReactNode;\n /** Hide the close button */\n hideCloseButton?: boolean;\n /** Modal variant */\n variant?: 'default' | 'activity';\n /** Description for activity variant */\n description?: string;\n /** Image URL for activity variant */\n image?: string;\n /** Alt text for activity image (leave empty for decorative images) */\n imageAlt?: string;\n /** Action link for activity variant */\n actionLink?: string;\n /** Action button label for activity variant */\n actionLabel?: string;\n};\n\n/**\n * Modal component for Analytica Ensino platforms\n *\n * A flexible modal component with multiple size variants and customizable behavior.\n *\n * @param isOpen - Whether the modal is currently open\n * @param onClose - Callback function called when the modal should be closed\n * @param title - The title displayed at the top of the modal\n * @param children - The main content of the modal\n * @param size - The size variant (xs, sm, md, lg, xl)\n * @param className - Additional CSS classes for the modal content\n * @param closeOnEscape - Whether pressing Escape closes the modal (default: true)\n * @param footer - Footer content, typically action buttons\n * @param hideCloseButton - Whether to hide the X close button (default: false)\n * @returns A modal overlay with content\n *\n * @example\n * ```tsx\n * <Modal\n * isOpen={isModalOpen}\n * onClose={() => setIsModalOpen(false)}\n * title=\"Invite your team\"\n * size=\"md\"\n * footer={\n * <div className=\"flex gap-3\">\n * <Button variant=\"outline\" onClick={() => setIsModalOpen(false)}>Cancel</Button>\n * <Button variant=\"solid\" onClick={handleExplore}>Explore</Button>\n * </div>\n * }\n * >\n * Elevate user interactions with our versatile modals.\n * </Modal>\n * ```\n */\nconst Modal = ({\n isOpen,\n onClose,\n title,\n children,\n size = 'md',\n className = '',\n closeOnEscape = true,\n footer,\n hideCloseButton = false,\n variant = 'default',\n description,\n image,\n imageAlt,\n actionLink,\n actionLabel,\n contentClassName = '',\n}: ModalProps) => {\n const titleId = useId();\n\n // Handle escape key\n useEffect(() => {\n if (!isOpen || !closeOnEscape) return;\n\n const handleEscape = (event: globalThis.KeyboardEvent) => {\n if (event.key === 'Escape') {\n onClose();\n }\n };\n\n document.addEventListener('keydown', handleEscape);\n return () => document.removeEventListener('keydown', handleEscape);\n }, [isOpen, closeOnEscape, onClose]);\n\n // Handle body scroll lock and scrollbar shift fix\n useEffect(() => {\n if (!isOpen) return;\n\n // Calculate scrollbar width before hiding overflow\n const scrollbarWidth =\n window.innerWidth - document.documentElement.clientWidth;\n\n // Save original styles\n const originalOverflow = document.body.style.overflow;\n const originalPaddingRight = document.body.style.paddingRight;\n\n // Apply scroll lock\n document.body.style.overflow = 'hidden';\n\n // Fix scrollbar shift: add padding to compensate for lost scrollbar\n if (scrollbarWidth > 0) {\n document.body.style.paddingRight = `${scrollbarWidth}px`;\n\n // Create overlay to cover the padding area with backdrop color\n const overlay = document.createElement('div');\n overlay.id = 'modal-scrollbar-overlay';\n overlay.style.cssText = `\n position: fixed;\n top: 0;\n right: 0;\n width: ${scrollbarWidth}px;\n height: 100vh;\n background-color: rgb(0 0 0 / 0.6);\n z-index: 40;\n pointer-events: none;\n `;\n document.body.appendChild(overlay);\n }\n\n return () => {\n // Restore original styles\n document.body.style.overflow = originalOverflow;\n document.body.style.paddingRight = originalPaddingRight;\n\n // Remove overlay\n const overlay = document.getElementById('modal-scrollbar-overlay');\n if (overlay) {\n overlay.remove();\n }\n };\n }, [isOpen]);\n\n if (!isOpen) return null;\n\n const sizeClasses = SIZE_CLASSES[size];\n const baseClasses =\n 'bg-secondary-50 rounded-3xl shadow-hard-shadow-2 border border-border-100 w-full mx-4';\n // Reset dialog default styles to prevent positioning issues\n const dialogResetClasses =\n 'p-0 m-0 border-none outline-none max-h-none static';\n const modalClasses = cn(\n baseClasses,\n sizeClasses,\n dialogResetClasses,\n className\n );\n\n // Normalize URLs missing protocol\n const normalizeUrl = (href: string) =>\n /^https?:\\/\\//i.test(href) ? href : `https://${href}`;\n\n // Handle action link click\n const handleActionClick = () => {\n if (actionLink) {\n window.open(normalizeUrl(actionLink), '_blank', 'noopener,noreferrer');\n }\n };\n\n // Activity variant rendering\n if (variant === 'activity') {\n return (\n <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs border-none p-0 m-0 w-full cursor-default\">\n <dialog\n className={modalClasses}\n aria-labelledby={titleId}\n aria-modal=\"true\"\n open\n >\n {/* Header simples com X */}\n <div className=\"flex justify-end p-6 pb-0\">\n {!hideCloseButton && (\n <button\n onClick={onClose}\n className=\"p-1 text-text-500 hover:text-text-700 hover:bg-background-50 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-indicator-info focus:ring-offset-2\"\n aria-label=\"Fechar modal\"\n >\n <X size={18} />\n </button>\n )}\n </div>\n\n {/* Conteúdo centralizado */}\n <div className=\"flex flex-col items-center px-6 pb-6 gap-5\">\n {/* Imagem ilustrativa */}\n {image && (\n <div className=\"flex justify-center\">\n <img\n src={image}\n alt={imageAlt ?? ''}\n className=\"w-[122px] h-[122px] object-contain\"\n />\n </div>\n )}\n\n {/* Título */}\n <h2\n id={titleId}\n className=\"text-lg font-semibold text-text-950 text-center\"\n >\n {title}\n </h2>\n\n {/* Descrição */}\n {description && (\n <p className=\"text-sm font-normal text-text-400 text-center max-w-md leading-[21px]\">\n {description}\n </p>\n )}\n\n {/* Ação: Botão ou Vídeo Embedado */}\n {actionLink && (\n <div className=\"w-full\">\n {(() => {\n const normalized = normalizeUrl(actionLink);\n const isYT = isYouTubeUrl(normalized);\n if (!isYT) return null;\n const id = getYouTubeVideoId(normalized);\n if (!id) {\n return (\n <Button\n variant=\"solid\"\n action=\"primary\"\n size=\"large\"\n className=\"w-full\"\n onClick={handleActionClick}\n >\n {actionLabel || 'Iniciar Atividade'}\n </Button>\n );\n }\n return (\n <iframe\n src={getYouTubeEmbedUrl(id)}\n className=\"w-full aspect-video rounded-lg\"\n allowFullScreen\n allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n title=\"Vídeo YouTube\"\n />\n );\n })()}\n {!isYouTubeUrl(normalizeUrl(actionLink)) && (\n <Button\n variant=\"solid\"\n action=\"primary\"\n size=\"large\"\n className=\"w-full\"\n onClick={handleActionClick}\n >\n {actionLabel || 'Iniciar Atividade'}\n </Button>\n )}\n </div>\n )}\n </div>\n </dialog>\n </div>\n );\n }\n\n // Default variant rendering\n return (\n <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs border-none p-0 m-0 w-full cursor-default\">\n <dialog\n className={modalClasses}\n aria-labelledby={titleId}\n aria-modal=\"true\"\n open\n >\n {/* Header */}\n <div className=\"flex items-center justify-between px-6 py-6\">\n <h2 id={titleId} className=\"text-lg font-semibold text-text-950\">\n {title}\n </h2>\n {!hideCloseButton && (\n <button\n onClick={onClose}\n className=\"p-1 text-text-500 hover:text-text-700 hover:bg-background-50 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-indicator-info focus:ring-offset-2\"\n aria-label=\"Fechar modal\"\n >\n <X size={18} />\n </button>\n )}\n </div>\n\n {/* Content */}\n {children && (\n <div className={cn('px-6 pb-6', contentClassName)}>\n <div className=\"text-text-500 font-normal text-sm leading-6\">\n {children}\n </div>\n </div>\n )}\n\n {/* Footer */}\n {footer && (\n <div className=\"flex justify-end gap-3 px-6 pb-6\">{footer}</div>\n )}\n </dialog>\n </div>\n );\n};\n\nexport default Modal;\n","import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n\nexport { syncDropdownState } from './dropdown';\nexport {\n getSelectedIdsFromCategories,\n toggleArrayItem,\n toggleSingleValue,\n areFiltersEqual,\n} from './activityFilters';\nexport {\n mapQuestionTypeToEnum,\n mapQuestionTypeToEnumRequired,\n} from './questionTypeUtils';\nexport {\n getStatusBadgeConfig,\n formatTimeSpent,\n formatQuestionNumbers,\n formatDateToBrazilian,\n} from './activityDetailsUtils';\n\n/**\n * Retorna a cor hexadecimal com opacidade 0.3 (4d) se não estiver em dark mode.\n * Se estiver em dark mode, retorna a cor original.\n *\n * @param hexColor - Cor hexadecimal (ex: \"#0066b8\" ou \"0066b8\")\n * @param isDark - booleano indicando se está em dark mode\n * @returns string - cor hexadecimal com opacidade se necessário\n */\nexport function getSubjectColorWithOpacity(\n hexColor: string | undefined,\n isDark: boolean\n): string | undefined {\n if (!hexColor) return undefined;\n // Remove o '#' se existir\n let color = hexColor.replace(/^#/, '').toLowerCase();\n\n if (isDark) {\n // Se está em dark mode, sempre remove opacidade se existir\n if (color.length === 8) {\n color = color.slice(0, 6);\n }\n return `#${color}`;\n } else {\n // Se não está em dark mode (light mode)\n let resultColor: string;\n if (color.length === 6) {\n // Adiciona opacidade 0.3 (4D) para cores de 6 dígitos\n resultColor = `#${color}4d`;\n } else if (color.length === 8) {\n // Já tem opacidade, retorna como está\n resultColor = `#${color}`;\n } else {\n // Para outros tamanhos (3, 4, 5 dígitos), retorna como está\n resultColor = `#${color}`;\n }\n return resultColor;\n }\n}\n","import { ButtonHTMLAttributes, ReactNode } from 'react';\nimport { cn } from '../../utils/utils';\n\n/**\n * Lookup table for variant and action class combinations\n */\nconst VARIANT_ACTION_CLASSES = {\n solid: {\n primary:\n 'bg-primary-950 text-text border border-primary-950 hover:bg-primary-800 hover:border-primary-800 focus-visible:outline-none focus-visible:bg-primary-950 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:bg-primary-700 active:border-primary-700 disabled:bg-primary-500 disabled:border-primary-500 disabled:opacity-40 disabled:cursor-not-allowed',\n positive:\n 'bg-success-500 text-text border border-success-500 hover:bg-success-600 hover:border-success-600 focus-visible:outline-none focus-visible:bg-success-500 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:bg-success-700 active:border-success-700 disabled:bg-success-500 disabled:border-success-500 disabled:opacity-40 disabled:cursor-not-allowed',\n negative:\n 'bg-error-500 text-text border border-error-500 hover:bg-error-600 hover:border-error-600 focus-visible:outline-none focus-visible:bg-error-500 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:bg-error-700 active:border-error-700 disabled:bg-error-500 disabled:border-error-500 disabled:opacity-40 disabled:cursor-not-allowed',\n },\n outline: {\n primary:\n 'bg-transparent text-primary-950 border border-primary-950 hover:bg-background-50 hover:text-primary-400 hover:border-primary-400 focus-visible:border-0 focus-visible:outline-none focus-visible:text-primary-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-primary-700 active:border-primary-700 disabled:opacity-40 disabled:cursor-not-allowed',\n positive:\n 'bg-transparent text-success-500 border border-success-300 hover:bg-background-50 hover:text-success-400 hover:border-success-400 focus-visible:border-0 focus-visible:outline-none focus-visible:text-success-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-success-700 active:border-success-700 disabled:opacity-40 disabled:cursor-not-allowed',\n negative:\n 'bg-transparent text-error-500 border border-error-300 hover:bg-background-50 hover:text-error-400 hover:border-error-400 focus-visible:border-0 focus-visible:outline-none focus-visible:text-error-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-error-700 active:border-error-700 disabled:opacity-40 disabled:cursor-not-allowed',\n },\n link: {\n primary:\n 'bg-transparent text-primary-950 hover:text-primary-400 focus-visible:outline-none focus-visible:text-primary-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-primary-700 disabled:opacity-40 disabled:cursor-not-allowed',\n positive:\n 'bg-transparent text-success-500 hover:text-success-400 focus-visible:outline-none focus-visible:text-success-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-success-700 disabled:opacity-40 disabled:cursor-not-allowed',\n negative:\n 'bg-transparent text-error-500 hover:text-error-400 focus-visible:outline-none focus-visible:text-error-600 focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-indicator-info active:text-error-700 disabled:opacity-40 disabled:cursor-not-allowed',\n },\n} as const;\n\n/**\n * Lookup table for size classes\n */\nconst SIZE_CLASSES = {\n 'extra-small': 'text-xs px-3.5 py-2',\n small: 'text-sm px-4 py-2.5',\n medium: 'text-md px-5 py-2.5',\n large: 'text-lg px-6 py-3',\n 'extra-large': 'text-lg px-7 py-3.5',\n} as const;\n\n/**\n * Button component props interface\n */\ntype ButtonProps = {\n /** Content to be displayed inside the button */\n children: ReactNode;\n /** Ícone à esquerda do texto */\n iconLeft?: ReactNode;\n /** Ícone à direita do texto */\n iconRight?: ReactNode;\n /** Size of the button */\n size?: 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large';\n /** Visual variant of the button */\n variant?: 'solid' | 'outline' | 'link';\n /** Action type of the button */\n action?: 'primary' | 'positive' | 'negative';\n /** Additional CSS classes to apply */\n className?: string;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\n/**\n * Button component for Analytica Ensino platforms\n *\n * A flexible button component with multiple variants, sizes and actions.\n *\n * @param children - The content to display inside the button\n * @param size - The size variant (extra-small, small, medium, large, extra-large)\n * @param variant - The visual style variant (solid, outline, link)\n * @param action - The action type (primary, positive, negative)\n * @param className - Additional CSS classes\n * @param props - All other standard button HTML attributes\n * @returns A styled button element\n *\n * @example\n * ```tsx\n * <Button variant=\"solid\" action=\"primary\" size=\"medium\" onClick={() => console.log('clicked')}>\n * Click me\n * </Button>\n * ```\n */\nconst Button = ({\n children,\n iconLeft,\n iconRight,\n size = 'medium',\n variant = 'solid',\n action = 'primary',\n className = '',\n disabled,\n type = 'button',\n ...props\n}: ButtonProps) => {\n // Get classes from lookup tables\n const sizeClasses = SIZE_CLASSES[size];\n const variantClasses = VARIANT_ACTION_CLASSES[variant][action];\n\n const baseClasses =\n 'inline-flex items-center justify-center rounded-full cursor-pointer font-medium';\n\n return (\n <button\n className={cn(baseClasses, variantClasses, sizeClasses, className)}\n disabled={disabled}\n type={type}\n {...props}\n >\n {iconLeft && <span className=\"mr-2 flex items-center\">{iconLeft}</span>}\n {children}\n {iconRight && <span className=\"ml-2 flex items-center\">{iconRight}</span>}\n </button>\n );\n};\n\nexport default Button;\n","/**\n * Video utilities for Modal component\n *\n * Utilities to handle YouTube video embedding and URL detection\n */\n\n/**\n * Check if a given URL is a YouTube URL\n *\n * @param url - The URL to check\n * @returns true if the URL is from YouTube, false otherwise\n */\nexport const isYouTubeUrl = (url: string): boolean => {\n const youtubeRegex =\n /^(https?:\\/\\/)?((www|m|music)\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)\\/.+/i;\n return youtubeRegex.test(url);\n};\n\n/**\n * Validate if hostname is a legitimate YouTube host\n *\n * @param host - The hostname to validate\n * @returns The type of YouTube host or null if invalid\n */\nconst isValidYouTubeHost = (\n host: string\n): 'youtu.be' | 'youtube' | 'nocookie' | null => {\n if (host === 'youtu.be') return 'youtu.be';\n\n const isValidYouTubeCom =\n host === 'youtube.com' ||\n (host.endsWith('.youtube.com') &&\n /^(www|m|music)\\.youtube\\.com$/.test(host));\n\n if (isValidYouTubeCom) return 'youtube';\n\n const isValidNoCookie =\n host === 'youtube-nocookie.com' ||\n (host.endsWith('.youtube-nocookie.com') &&\n /^(www|m|music)\\.youtube-nocookie\\.com$/.test(host));\n\n if (isValidNoCookie) return 'nocookie';\n\n return null;\n};\n\n/**\n * Extract video ID from youtu.be path\n *\n * @param pathname - The URL pathname\n * @returns The video ID or null\n */\nconst extractYoutuBeId = (pathname: string): string | null => {\n const firstSeg = pathname.split('/').filter(Boolean)[0];\n return firstSeg || null;\n};\n\n/**\n * Extract video ID from YouTube or nocookie domains\n *\n * @param pathname - The URL pathname\n * @param searchParams - The URL search parameters\n * @returns The video ID or null\n */\nconst extractYouTubeId = (\n pathname: string,\n searchParams: URLSearchParams\n): string | null => {\n const parts = pathname.split('/').filter(Boolean);\n const [first, second] = parts;\n\n if (first === 'embed' && second) return second;\n if (first === 'shorts' && second) return second;\n if (first === 'live' && second) return second;\n\n const v = searchParams.get('v');\n if (v) return v;\n\n return null;\n};\n\n/**\n * Extract YouTube video ID from URL\n *\n * @param url - The YouTube URL\n * @returns The video ID if found, null otherwise\n */\nexport const getYouTubeVideoId = (url: string): string | null => {\n try {\n const u = new URL(url);\n const hostType = isValidYouTubeHost(u.hostname.toLowerCase());\n\n if (!hostType) return null;\n\n if (hostType === 'youtu.be') {\n return extractYoutuBeId(u.pathname);\n }\n\n return extractYouTubeId(u.pathname, u.searchParams);\n } catch {\n return null;\n }\n};\n\n/**\n * Generate YouTube embed URL for iframe\n *\n * @param videoId - The YouTube video ID\n * @returns The embed URL for the video\n */\nexport const getYouTubeEmbedUrl = (videoId: string): string => {\n return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=0&rel=0&modestbranding=1`;\n};\n","import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';\nimport { cn } from '../../utils/utils';\n\n/**\n * Base text component props\n */\ntype BaseTextProps = {\n /** Content to be displayed */\n children?: ReactNode;\n /** Text size variant */\n size?:\n | '2xs'\n | 'xs'\n | 'sm'\n | 'md'\n | 'lg'\n | 'xl'\n | '2xl'\n | '3xl'\n | '4xl'\n | '5xl'\n | '6xl';\n /** Font weight variant */\n weight?:\n | 'hairline'\n | 'light'\n | 'normal'\n | 'medium'\n | 'semibold'\n | 'bold'\n | 'extrabold'\n | 'black';\n /** Color variant - white for light backgrounds, black for dark backgrounds */\n color?: string;\n /** Additional CSS classes to apply */\n className?: string;\n};\n\n/**\n * Polymorphic text component props that ensures type safety based on the 'as' prop\n */\ntype TextProps<T extends ElementType = 'p'> = BaseTextProps & {\n /** HTML tag to render */\n as?: T;\n} & Omit<ComponentPropsWithoutRef<T>, keyof BaseTextProps>;\n\n/**\n * Text component for Analytica Ensino platforms\n *\n * A flexible polymorphic text component with multiple sizes, weights, and colors.\n * Automatically adapts to dark and light themes with full type safety.\n *\n * @param children - The content to display\n * @param size - The text size variant (2xs, xs, sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl)\n * @param weight - The font weight variant (hairline, light, normal, medium, semibold, bold, extrabold, black)\n * @param color - The color variant - adapts to theme\n * @param as - The HTML tag to render - determines allowed attributes via TypeScript\n * @param className - Additional CSS classes\n * @param props - HTML attributes valid for the chosen tag only\n * @returns A styled text element with type-safe attributes\n *\n * @example\n * ```tsx\n * <Text size=\"lg\" weight=\"bold\" color=\"text-info-800\">\n * This is a large, bold text\n * </Text>\n *\n * <Text as=\"a\" href=\"/link\" target=\"_blank\">\n * Link with type-safe anchor attributes\n * </Text>\n *\n * <Text as=\"button\" onClick={handleClick} disabled>\n * Button with type-safe button attributes\n * </Text>\n * ```\n */\nconst Text = <T extends ElementType = 'p'>({\n children,\n size = 'md',\n weight = 'normal',\n color = 'text-text-950',\n as,\n className = '',\n ...props\n}: TextProps<T>) => {\n let sizeClasses = '';\n let weightClasses = '';\n\n // Text size classes mapping\n const sizeClassMap = {\n '2xs': 'text-2xs',\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-md',\n lg: 'text-lg',\n xl: 'text-xl',\n '2xl': 'text-2xl',\n '3xl': 'text-3xl',\n '4xl': 'text-4xl',\n '5xl': 'text-5xl',\n '6xl': 'text-6xl',\n } as const;\n\n sizeClasses = sizeClassMap[size] ?? sizeClassMap.md;\n\n // Font weight classes mapping\n const weightClassMap = {\n hairline: 'font-hairline',\n light: 'font-light',\n normal: 'font-normal',\n medium: 'font-medium',\n semibold: 'font-semibold',\n bold: 'font-bold',\n extrabold: 'font-extrabold',\n black: 'font-black',\n } as const;\n\n weightClasses = weightClassMap[weight] ?? weightClassMap.normal;\n\n const baseClasses = 'font-primary';\n const Component = as ?? ('p' as ElementType);\n\n return (\n <Component\n className={cn(baseClasses, sizeClasses, weightClasses, color, className)}\n {...props}\n >\n {children}\n </Component>\n );\n};\n\nexport default Text;\n","import Text from '../Text/Text';\nimport { Check } from 'phosphor-react';\nimport { cn } from '../../utils/utils';\n\n/**\n * Stepper size variants\n */\ntype StepperSize = 'small' | 'medium' | 'large' | 'extraLarge';\n\n/**\n * Step state variants\n */\ntype StepState = 'pending' | 'current' | 'completed';\n\n/**\n * Individual step data interface\n */\nexport interface StepData {\n /** Unique identifier for the step */\n id: string;\n /** Label text for the step */\n label: string;\n /** Current state of the step */\n state: StepState;\n}\n\n/**\n * Size configurations - Following design system pattern from CSS specifications\n * Small size based on exact CSS: width 58px, height 38px, gap 8px\n */\nconst SIZE_CLASSES = {\n small: {\n container: 'gap-2', // 8px gap as specified in CSS\n stepWidth: 'w-[58px]', // exact 58px from CSS\n stepHeight: 'h-[38px]', // exact 38px from CSS\n indicator: 'w-5 h-5', // 20px as specified\n progressBar: 'h-0.5', // 2px as specified\n indicatorTextSize: '2xs', // 10px as specified\n labelTextSize: 'xs', // 12px as specified\n iconSize: 'w-3 h-3', // 12px\n },\n medium: {\n container: 'gap-3', // 12px (8px + 4px progression)\n stepWidth: 'w-[110px]', // 110px (increased from 90px to fit \"Endereço Residencial\")\n stepHeight: 'h-[48px]', // 48px (increased from 46px for better proportion)\n indicator: 'w-6 h-6', // 24px (20px + 4px progression)\n progressBar: 'h-0.5', // 2px maintained for consistency\n indicatorTextSize: '2xs', // 10px maintained for readability\n labelTextSize: 'xs', // 12px maintained\n iconSize: 'w-3.5 h-3.5', // 14px\n },\n large: {\n container: 'gap-4', // 16px (12px + 4px progression)\n stepWidth: 'w-[160px]', // 160px (increased from 140px to fit \"Endereço Residencial\")\n stepHeight: 'h-[58px]', // 58px (increased from 54px for better proportion)\n indicator: 'w-7 h-7', // 28px (24px + 4px progression)\n progressBar: 'h-1', // 4px (increased for better visibility)\n indicatorTextSize: 'xs', // 12px (increased for larger size)\n labelTextSize: 'sm', // 14px (increased for larger size)\n iconSize: 'w-4 h-4', // 16px\n },\n extraLarge: {\n container: 'gap-5', // 20px (16px + 4px progression)\n stepWidth: 'w-[200px]', // 200px (increased from 180px to ensure \"Endereço Residencial\" fits)\n stepHeight: 'h-[68px]', // 68px (increased from 62px for better proportion)\n indicator: 'w-8 h-8', // 32px (28px + 4px progression)\n progressBar: 'h-1', // 4px maintained\n indicatorTextSize: 'xs', // 12px maintained for readability\n labelTextSize: 'sm', // 14px maintained\n iconSize: 'w-[18px] h-[18px]', // 18px\n },\n} as const;\n\n/**\n * State configurations using exact colors from CSS specs\n * pending: #A3A3A3 = text-400 (etapa ainda não iniciada)\n * current: #1C61B2 = primary-800 (etapa atual sendo preenchida) - baseado no CSS fornecido\n * completed: #1C61B2 = primary-800 (etapa concluída)\n * text color: #FEFEFF = text\n */\nconst STATE_CLASSES = {\n pending: {\n progressBar: 'bg-text-400', // #A3A3A3\n indicator: 'bg-text-400', // #A3A3A3\n indicatorText: 'text-white', // Branco para contraste com background cinza\n label: 'text-text-400', // #A3A3A3\n },\n current: {\n progressBar: 'bg-primary-800', // #1C61B2 usando classe Tailwind padrão\n indicator: 'bg-primary-800', // #1C61B2 usando classe Tailwind padrão\n indicatorText: 'text-white', // Branco usando classe Tailwind padrão\n label: 'text-primary-800', // #1C61B2 usando classe Tailwind padrão\n },\n completed: {\n progressBar: 'bg-primary-400', // #48A0E8 para barra quando checked (completed)\n indicator: 'bg-primary-400', // #48A0E8 para corresponder à barra de progresso\n indicatorText: 'text-white', // Branco usando classe Tailwind padrão\n label: 'text-primary-400', // #48A0E8 para corresponder à barra de progresso\n },\n} as const;\n\n/**\n * Type for size classes\n */\ntype SizeClassType = (typeof SIZE_CLASSES)[keyof typeof SIZE_CLASSES];\n\n/**\n * Type for state classes\n */\ntype StateClassType = (typeof STATE_CLASSES)[keyof typeof STATE_CLASSES];\n\n/**\n * Props for individual step component\n */\ninterface StepProps {\n step: StepData;\n index: number;\n size: StepperSize;\n sizeClasses: SizeClassType;\n stateClasses: StateClassType;\n isLast: boolean;\n className?: string;\n}\n\n/**\n * Individual Step component - Based on exact design specifications\n * Layout: flex-column with progress bar at top, then icon+label row\n * Fully responsive for mobile, tablets, and laptops\n */\nexport const Step = ({\n step,\n index,\n size: _size,\n sizeClasses,\n stateClasses,\n isLast: _isLast,\n className = '',\n}: StepProps) => {\n const stepNumber = index + 1;\n const isCompleted = step.state === 'completed';\n\n // Generate accessible aria-label based on step state\n const getAriaLabel = () => {\n let suffix = '';\n if (step.state === 'completed') {\n suffix = ' (concluído)';\n } else if (step.state === 'current') {\n suffix = ' (atual)';\n }\n return `${step.label}${suffix}`;\n };\n\n return (\n <div\n className={`\n flex flex-col justify-center items-center pb-2 gap-2\n ${sizeClasses.stepWidth} ${sizeClasses.stepHeight}\n flex-none flex-grow\n ${className}\n sm:max-w-[100px] md:max-w-[120px] lg:max-w-none xl:max-w-none\n sm:min-h-[40px] md:min-h-[45px] lg:min-h-none\n overflow-visible\n `}\n >\n {/* Progress Bar - Full width at top with responsive scaling */}\n <div\n className={`\n w-full ${sizeClasses.progressBar} ${stateClasses.progressBar}\n rounded-sm flex-none\n `}\n />\n\n {/* Step Content Container - Responsive layout for all devices, no vertical scroll */}\n <div\n className={`\n flex flex-col sm:flex-row items-center\n gap-1 sm:gap-2 w-full sm:w-auto\n h-auto sm:h-5 flex-none\n overflow-visible\n `}\n >\n {/* Step Indicator Circle with responsive sizing */}\n <div\n className={`\n ${sizeClasses.indicator} ${stateClasses.indicator}\n rounded-full flex items-center justify-center relative\n flex-none transition-all duration-300 ease-out\n `}\n aria-label={getAriaLabel()}\n >\n {isCompleted ? (\n <Check\n weight=\"bold\"\n className={`\n ${stateClasses.indicatorText}\n w-2.5 h-2.5 sm:w-3 sm:h-3 md:w-3 md:h-3 lg:w-3.5 lg:h-3.5\n `}\n />\n ) : (\n <Text\n size={sizeClasses.indicatorTextSize as '2xs' | 'xs' | 'sm'}\n weight=\"medium\"\n color=\"\"\n className={cn(stateClasses.indicatorText, 'leading-none')}\n >\n {stepNumber}\n </Text>\n )}\n </div>\n\n {/* Step Label with full responsive text sizing, no vertical overflow */}\n <Text\n size={sizeClasses.labelTextSize as '2xs' | 'xs' | 'sm' | 'md'}\n weight=\"medium\"\n color=\"\"\n className={cn(\n stateClasses.label,\n 'leading-tight flex-none text-center sm:text-left break-words px-1 sm:px-0 max-w-full whitespace-normal'\n )}\n >\n {step.label}\n </Text>\n </div>\n </div>\n );\n};\n\n/**\n * Stepper component props interface\n */\nexport type StepperProps = {\n /** Array of step data */\n steps: StepData[];\n /** Size variant of the stepper */\n size?: StepperSize;\n /** Current active step index */\n currentStep?: number;\n /** Additional CSS classes */\n className?: string;\n /** Step container CSS classes */\n stepClassName?: string;\n /** Progress indicator (e.g., \"Etapa 2 de 4\") */\n showProgress?: boolean;\n /** Custom progress text */\n progressText?: string;\n /** Make stepper responsive (vertical on mobile) */\n responsive?: boolean;\n};\n\n/**\n * Helper function to calculate step states based on current step\n */\nconst calculateStepStates = (\n steps: StepData[],\n currentStep: number\n): StepData[] => {\n return steps.map((step, index) => {\n let stepState: StepState;\n\n if (index < currentStep) {\n stepState = 'completed';\n } else if (index === currentStep) {\n stepState = 'current';\n } else {\n stepState = 'pending';\n }\n\n return {\n ...step,\n state: stepState,\n };\n });\n};\n\n/**\n * Helper function to get progress text\n */\nconst getProgressText = (\n currentStep: number,\n totalSteps: number,\n customText?: string\n): string => {\n if (customText) return customText;\n return `Etapa ${currentStep + 1} de ${totalSteps}`;\n};\n\n/**\n * Stepper component for Analytica Ensino platforms\n *\n * A progress stepper component that displays a sequence of steps with different states.\n * Follows the exact design specifications with proper spacing, colors, and layout.\n * Fully responsive for mobile, tablets, and laptops.\n *\n * Design specifications:\n * - Based on exact CSS specifications from Figma design\n * - Fully responsive: mobile (320px+) -> tablets (640px+) -> laptops (1024px+)\n * - Progressive sizing with responsive breakpoints that adapt to device type\n * - Consistent gaps that scale