UNPKG

analytica-frontend-lib

Version:

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

1 lines 77.4 kB
{"version":3,"sources":["../src/components/AccessibilityWidget/fabPositioning.ts","../src/components/AccessibilityWidget/AccessibilityFab.tsx","../src/components/AccessibilityWidget/AccessibilityPanel.tsx","../src/components/AccessibilityWidget/AccessibilityToggleRow.tsx","../src/components/AccessibilityWidget/TTSSection.tsx","../src/components/AccessibilityWidget/LibrasFab.tsx","../src/components/AccessibilityWidget/ReadingAid.tsx","../src/components/AccessibilityWidget/ColorBlindFilters.tsx","../src/components/AccessibilityWidget/TTSController.tsx","../src/components/AccessibilityWidget/VLibrasLoader.tsx","../src/components/AccessibilityWidget/AccessibilityWidget.tsx"],"sourcesContent":["/**\n * Constantes de posicionamento compartilhadas entre os FABs do widget\n * (`AccessibilityFab` e `LibrasFab`). Centralizadas aqui para evitar\n * que mudanças de gap/lateral precisem ser replicadas em cada FAB.\n */\n\nexport type FabPosition = 'right' | 'left';\n\n/**\n * Alinhamento vertical do FAB. Quando o widget renderiza só um botão\n * (sem Libras), `center` deixa-o no meio da viewport. Quando há também\n * o LibrasFab empilhado abaixo, o de acessibilidade fica `above-center`\n * e o Libras fica `below-center` — formando um par grudado no meio.\n */\nexport type FabVerticalAlign = 'center' | 'above-center' | 'below-center';\n\nexport const FAB_POSITION_CLASSES: Record<FabPosition, string> = {\n right: 'right-0 rounded-l-lg',\n left: 'left-0 rounded-r-lg',\n};\n\nexport const FAB_VERTICAL_ALIGN_CLASSES: Record<FabVerticalAlign, string> = {\n center: 'top-1/2 -translate-y-1/2',\n // Pares empilhados (acessibilidade + Libras) com gap mínimo entre si,\n // como no widget do HandTalk. Cada FAB tem 40px (h-10).\n 'above-center': 'top-[calc(50%-1px)] -translate-y-full',\n 'below-center': 'top-[calc(50%+1px)]',\n};\n\n/** Tooltip aparece do lado oposto à borda em que o FAB está colado. */\nexport const FAB_TOOLTIP_POSITION: Record<FabPosition, 'left' | 'right'> = {\n right: 'left',\n left: 'right',\n};\n","import Button from '../Button/Button';\nimport { Tooltip } from '../Tooltip/Tooltip';\nimport { cn } from '../../utils/utils';\nimport accessibilityIcon from '../../assets/img/accessibility.png';\nimport {\n FAB_POSITION_CLASSES,\n FAB_TOOLTIP_POSITION,\n FAB_VERTICAL_ALIGN_CLASSES,\n type FabPosition,\n type FabVerticalAlign,\n} from './fabPositioning';\n\n/** Re-exports mantidos para compatibilidade com a API pública do widget. */\nexport type AccessibilityFabPosition = FabPosition;\nexport type AccessibilityFabVerticalAlign = FabVerticalAlign;\n\nexport interface AccessibilityFabProps {\n /** Click handler — alterna o painel */\n onClick: () => void;\n /** Indica se o painel está aberto (controla aria-expanded) */\n isOpen?: boolean;\n /** Lado da viewport onde o botão fica colado */\n position?: AccessibilityFabPosition;\n /** Alinhamento vertical (default: `center`) */\n verticalAlign?: AccessibilityFabVerticalAlign;\n /** Classes extras */\n className?: string;\n}\n\n/**\n * Botão flutuante (FAB) que abre o painel de acessibilidade.\n * Inspirado no padrão HandTalk: quadrado azul escuro colado na\n * lateral da viewport (direita por padrão), com o ícone universal\n * de acessibilidade. Verticalmente centralizado.\n */\nexport default function AccessibilityFab({\n onClick,\n isOpen = false,\n position = 'right',\n verticalAlign = 'center',\n className,\n}: Readonly<AccessibilityFabProps>) {\n const label = isOpen\n ? 'Fechar opções de acessibilidade'\n : 'Opções de acessibilidade';\n\n return (\n <Tooltip\n content={label}\n position={FAB_TOOLTIP_POSITION[position]}\n className={cn(\n 'fixed z-40',\n FAB_VERTICAL_ALIGN_CLASSES[verticalAlign],\n FAB_POSITION_CLASSES[position]\n )}\n >\n <Button\n variant=\"raw\"\n onClick={onClick}\n aria-label={label}\n aria-expanded={isOpen}\n data-testid=\"accessibility-fab\"\n className={cn(\n 'a11y-widget-shield',\n FAB_POSITION_CLASSES[position],\n 'flex h-10 w-10 cursor-pointer items-center justify-center',\n // `text-text-50` flipa junto com `bg-info-900` (claro no light,\n // escuro no dark). `text-white` deixaria o ícone branco sumindo\n // no tema escuro, onde `bg-info-900` resolve pra azul-claro.\n 'bg-info-900 text-text-50 shadow-lg',\n 'transition-all duration-200 hover:scale-110 hover:bg-info-800',\n 'focus:outline-none focus:ring-4 focus:ring-info-300',\n className\n )}\n >\n <img\n src={accessibilityIcon}\n alt=\"\"\n aria-hidden=\"true\"\n className=\"h-7 w-7\"\n />\n </Button>\n </Tooltip>\n );\n}\n","import { useEffect, useRef, useState, type ReactNode } from 'react';\nimport {\n ArrowCounterClockwiseIcon,\n CaretDownIcon,\n EyeIcon,\n FilmReelIcon,\n KeyboardIcon,\n NavigationArrowIcon,\n SpeakerHighIcon,\n TextTIcon,\n XIcon,\n} from '@phosphor-icons/react';\nimport accessibilityIcon from '../../assets/img/accessibility.png';\nimport Text from '../Text/Text';\nimport Button from '../Button/Button';\nimport IconButton from '../IconButton/IconButton';\nimport AccessibilityToggleRow from './AccessibilityToggleRow';\nimport TTSSection from './TTSSection';\nimport { cn } from '../../utils/utils';\nimport {\n useAccessibilityStore,\n type ContrastMode,\n type SaturationMode,\n type ReadingAid,\n} from '../../store/accessibilityStore';\nimport type { AccessibilityFabPosition } from './AccessibilityFab';\n\nconst CONTRAST_OPTIONS: { value: ContrastMode; label: string }[] = [\n { value: 'normal', label: 'Normal' },\n { value: 'high', label: 'Alto' },\n { value: 'inverted', label: 'Invertido' },\n];\n\nconst SATURATION_OPTIONS: { value: SaturationMode; label: string }[] = [\n { value: 'normal', label: 'Normal' },\n { value: 'low', label: 'Baixa' },\n { value: 'grayscale', label: 'Tons de cinza' },\n];\n\nconst READING_AID_OPTIONS: { value: ReadingAid; label: string }[] = [\n { value: 'none', label: 'Nenhum' },\n { value: 'ruler', label: 'Régua' },\n { value: 'mask', label: 'Máscara' },\n];\n\nconst LEVEL_LABELS = ['Padrão', 'Pequeno', 'Médio', 'Grande'] as const;\n\n/** Tamanho de fonte do preview \"Aa\" em cada botão (Padrão → Grande). */\nconst FONT_SIZE_PREVIEW_CLASSES = [\n 'text-base', // Padrão\n 'text-lg', // Pequeno (= maior que Padrão, segue a escala incremental do widget)\n 'text-2xl', // Médio\n 'text-4xl', // Grande\n] as const;\n\n/** Letter-spacing do preview \"Aa\" em cada botão. */\nconst LETTER_SPACING_PREVIEW_CLASSES = [\n 'tracking-tighter', // Padrão (junto)\n 'tracking-normal',\n 'tracking-wider',\n 'tracking-widest',\n] as const;\n\n/** Gap vertical entre as 3 barras do preview de espaçamento entre linhas. */\nconst LINE_SPACING_PREVIEW_GAPS = [\n 'gap-0.5', // Padrão (2px) — linhas próximas\n 'gap-1.5', // Pequeno (6px)\n 'gap-2.5', // Médio (10px)\n 'gap-3.5', // Grande (14px)\n] as const;\n\n/**\n * `<dialog open>` aplica `left: 0; right: 0` como user-agent style. Sem\n * resetar o lado oposto para `auto`, o painel acaba grudando no lado\n * errado mesmo com `right-6` ou `left-6` aplicado.\n */\nconst PANEL_EDGE_CLASSES: Record<AccessibilityFabPosition, string> = {\n right: 'right-10 left-auto',\n left: 'left-10 right-auto',\n};\n\ninterface AccordionSectionProps {\n title: string;\n icon: ReactNode;\n defaultExpanded?: boolean;\n testId?: string;\n children: ReactNode;\n}\n\n/**\n * Item de accordion estilo lista (sem cantos arredondados nem card),\n * com ícone + título à esquerda e caret à direita. Cada item separado\n * do próximo por uma linha fina. Mantém a estrutura visual do design\n * do Figma sem usar o `CardAccordation` da lib (que tem visual de card).\n */\nconst AccordionSection = ({\n title,\n icon,\n defaultExpanded = false,\n testId,\n children,\n}: Readonly<AccordionSectionProps>) => {\n const [expanded, setExpanded] = useState(defaultExpanded);\n return (\n <section className=\"border-b border-background-200 last:border-b-0\">\n <Button\n variant=\"raw\"\n aria-expanded={expanded}\n onClick={() => setExpanded((v) => !v)}\n data-testid={testId}\n className={cn(\n 'flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3',\n 'text-left transition-colors hover:bg-background-50',\n 'focus:outline-none focus-visible:ring-2 focus-visible:ring-info-500 focus-visible:ring-inset'\n )}\n >\n <span className=\"flex items-center gap-2\">\n <span className=\"text-text-800\" aria-hidden=\"true\">\n {icon}\n </span>\n <Text\n size=\"md\"\n weight=\"semibold\"\n className=\"leading-none text-text-800\"\n >\n {title}\n </Text>\n </span>\n <CaretDownIcon\n size={16}\n weight=\"bold\"\n aria-hidden=\"true\"\n className={cn(\n 'text-text-700 transition-transform duration-200',\n expanded ? 'rotate-180' : 'rotate-0'\n )}\n />\n </Button>\n {expanded && (\n <div className=\"flex flex-col gap-6 px-4 pb-4 pt-2\">{children}</div>\n )}\n </section>\n );\n};\n\ninterface SubControlProps {\n label: string;\n children: ReactNode;\n}\n\n/**\n * Wrapper para uma control (segmented/toggle) com título dentro do\n * accordion. Tipografia do label segue o Figma: Roboto 12px/500/100%\n * uppercase, cor `text-text-600` (#737373).\n */\nconst SubControl = ({ label, children }: Readonly<SubControlProps>) => (\n <div className=\"flex flex-col gap-2\">\n <Text\n size=\"xs\"\n weight=\"medium\"\n className=\"uppercase leading-none text-text-600\"\n >\n {label}\n </Text>\n {children}\n </div>\n);\n\ninterface SegmentedProps<T extends string | number> {\n value: T;\n options: { value: T; label: string }[];\n onChange: (value: T) => void;\n ariaLabel: string;\n}\n\n/**\n * Estilo de segmented baseado no `SelectionButton` da lib: cada opção é\n * um botão independente com `border` + `bg-background`, e o selecionado\n * ganha `ring-primary-950` (mesma identidade visual do componente da lib,\n * sem o overhead de ícone obrigatório do SelectionButton).\n */\nconst Segmented = <T extends string | number>({\n value,\n options,\n onChange,\n ariaLabel,\n}: Readonly<SegmentedProps<T>>) => (\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n className=\"flex flex-wrap gap-2\"\n >\n {options.map((opt) => {\n const isActive = opt.value === value;\n return (\n <Button\n key={String(opt.value)}\n variant=\"raw\"\n role=\"radio\"\n aria-checked={isActive}\n onClick={() => onChange(opt.value)}\n className={cn(\n 'flex-1 cursor-pointer rounded-xl border border-border-50 bg-background',\n // Texto segue spec do Figma: Roboto 14px/700/100%/0.2px, cor\n // #525252 (text-700). Quando ativo, troca pra primary-950\n // (azul escuro), mas mantém a mesma typography.\n 'px-4 py-4 text-sm font-bold leading-none tracking-[0.2px] text-text-700',\n 'shadow-soft-shadow-1 transition-all duration-150',\n 'hover:bg-background-100',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indicator-info',\n isActive &&\n 'ring-2 ring-primary-950 ring-offset-0 shadow-none text-primary-950'\n )}\n >\n {opt.label}\n </Button>\n );\n })}\n </div>\n);\n\ninterface PreviewSegmentedOption<T extends string | number> {\n value: T;\n label: string;\n /** Conteúdo visual exibido DENTRO do botão (preview do efeito). */\n preview: ReactNode;\n}\n\ninterface PreviewSegmentedProps<T extends string | number> {\n value: T;\n options: PreviewSegmentedOption<T>[];\n onChange: (value: T) => void;\n ariaLabel: string;\n}\n\n/**\n * Variante do Segmented onde o botão mostra um preview visual do efeito\n * (ex.: \"Aa\" em tamanhos diferentes para \"Tamanho da fonte\") e o label\n * textual fica abaixo do botão como legenda.\n */\nconst PreviewSegmented = <T extends string | number>({\n value,\n options,\n onChange,\n ariaLabel,\n}: Readonly<PreviewSegmentedProps<T>>) => (\n <div role=\"radiogroup\" aria-label={ariaLabel} className=\"flex gap-2\">\n {options.map((opt) => {\n const isActive = opt.value === value;\n return (\n <div\n key={String(opt.value)}\n className=\"flex flex-1 flex-col items-center gap-2\"\n >\n <Button\n variant=\"raw\"\n role=\"radio\"\n aria-checked={isActive}\n aria-label={opt.label}\n onClick={() => onChange(opt.value)}\n className={cn(\n 'flex aspect-square w-full items-center justify-center',\n 'cursor-pointer rounded-xl border border-border-50 bg-background',\n 'text-text-700 shadow-soft-shadow-1 transition-all duration-150',\n 'hover:bg-background-100',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indicator-info',\n isActive &&\n 'ring-2 ring-primary-950 ring-offset-0 shadow-none text-primary-950'\n )}\n >\n {opt.preview}\n </Button>\n <Text\n size=\"xs\"\n weight=\"normal\"\n className=\"leading-none text-text-700\"\n >\n {opt.label}\n </Text>\n </div>\n );\n })}\n </div>\n);\n\nexport interface AccessibilityPanelProps {\n isOpen: boolean;\n onClose: () => void;\n /** Lado da viewport onde o painel aparece (default: 'right') */\n position?: AccessibilityFabPosition;\n className?: string;\n}\n\n/**\n * Painel lateral com os controles de acessibilidade organizados em\n * accordions colapsáveis (Visão, Leitura, Leitor de texto, Animação,\n * Navegação, Atalho de teclado).\n *\n * O próprio painel fica dentro de `.a11y-widget-shield`, então\n * filtros aplicados no `<html>` (alto contraste, etc.) não afetam\n * sua legibilidade.\n */\nexport default function AccessibilityPanel({\n isOpen,\n onClose,\n position = 'right',\n className,\n}: Readonly<AccessibilityPanelProps>) {\n const closeButtonRef = useRef<HTMLButtonElement>(null);\n const previouslyFocusedRef = useRef<Element | null>(null);\n\n const {\n contrastMode,\n saturationMode,\n fontSize,\n letterSpacing,\n lineSpacing,\n highlightLinks,\n pauseAnimations,\n bigCursor,\n dyslexiaFont,\n readingAid,\n keyboardShortcut,\n setContrastMode,\n setSaturationMode,\n setFontSize,\n setLetterSpacing,\n setLineSpacing,\n setHighlightLinks,\n setPauseAnimations,\n setBigCursor,\n setDyslexiaFont,\n setReadingAid,\n setKeyboardShortcut,\n resetPreferences,\n } = useAccessibilityStore();\n\n // Foco e Escape: ao abrir, lembra o elemento anteriormente focado,\n // move o foco para o botão de fechar e escuta a tecla Esc. Restaura\n // o foco anterior ao fechar.\n useEffect(() => {\n if (!isOpen) return;\n previouslyFocusedRef.current = document.activeElement;\n closeButtonRef.current?.focus();\n\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.stopPropagation();\n onClose();\n }\n };\n globalThis.addEventListener('keydown', handleKeyDown);\n\n return () => {\n globalThis.removeEventListener('keydown', handleKeyDown);\n const previous = previouslyFocusedRef.current as HTMLElement | null;\n if (previous && typeof previous.focus === 'function') {\n previous.focus();\n }\n };\n }, [isOpen, onClose]);\n\n if (!isOpen) return null;\n\n const levelOptions = LEVEL_LABELS.map((label, index) => ({\n value: index as 0 | 1 | 2 | 3,\n label,\n }));\n\n return (\n <dialog\n open\n aria-label=\"Opções de acessibilidade\"\n data-testid=\"accessibility-panel\"\n // `<dialog>` não estica automaticamente com `top` + `bottom`, por\n // isso a altura fica explícita: `100vh - 5rem` (80px de respiro do\n // topo). Combinado com `bottom-0` no className, o painel encosta\n // na base da viewport como no Figma.\n style={{ height: 'calc(100vh - 5rem)' }}\n className={cn(\n 'a11y-widget-shield',\n 'fixed bottom-0 z-40 m-0',\n PANEL_EDGE_CLASSES[position],\n 'flex w-[calc(100vw-3rem)] max-w-110 flex-col overflow-hidden rounded-2xl p-0',\n 'border border-background-200 bg-background shadow-2xl',\n className\n )}\n >\n {/* Header — visual do Figma: bg azul-claro (Paraná) / rosa-claro\n (Paraíba) usando `primary-50`. Preferido em vez de `map-attention`\n porque `primary-50` flipa corretamente no dark mode em todos os\n temas (ex.: Paraná dark = #27344a), enquanto `map-attention`\n ficou idêntico em light/dark no theme do Paraná, deixando o\n texto branco invisível sobre o azul-claro no dark. */}\n <header className=\"flex items-center justify-between gap-3 bg-primary-50 px-4 py-3\">\n <div className=\"flex min-w-0 items-center gap-3\">\n <img\n src={accessibilityIcon}\n alt=\"\"\n aria-hidden=\"true\"\n className=\"h-10 w-10 shrink-0\"\n />\n <div className=\"flex min-w-0 flex-col gap-1\">\n <Text\n size=\"md\"\n weight=\"bold\"\n className=\"leading-none tracking-[0.2px] text-text-950\"\n >\n Acessibilidade\n </Text>\n <Text\n size=\"xs\"\n weight=\"normal\"\n className=\"leading-none text-text-800\"\n >\n Personalize a leitura e navegação\n </Text>\n </div>\n </div>\n <IconButton\n ref={closeButtonRef}\n size=\"sm\"\n aria-label=\"Fechar opções de acessibilidade\"\n onClick={onClose}\n icon={<XIcon size={18} />}\n />\n </header>\n\n <div className=\"flex-1 overflow-y-auto\">\n <AccordionSection\n title=\"Visão\"\n icon={<EyeIcon size={18} />}\n testId=\"a11y-section-vision\"\n >\n <SubControl label=\"Contraste\">\n <Segmented\n ariaLabel=\"Modo de contraste\"\n value={contrastMode}\n options={CONTRAST_OPTIONS}\n onChange={setContrastMode}\n />\n </SubControl>\n <SubControl label=\"Saturação\">\n <Segmented\n ariaLabel=\"Modo de saturação\"\n value={saturationMode}\n options={SATURATION_OPTIONS}\n onChange={setSaturationMode}\n />\n </SubControl>\n {/* Daltonismo removido por enquanto — store/store actions\n mantidos para reativação futura sem refactor. */}\n </AccordionSection>\n\n <AccordionSection\n title=\"Leitura\"\n icon={<TextTIcon size={18} />}\n testId=\"a11y-section-reading\"\n >\n <SubControl label=\"Fonte para dislexia\">\n <AccessibilityToggleRow\n ariaLabel=\"Trocar para Comic Sans MS\"\n rowTestId=\"a11y-toggle-dyslexia-font-row\"\n switchTestId=\"a11y-toggle-dyslexia-font\"\n checked={dyslexiaFont}\n onChange={() => setDyslexiaFont(!dyslexiaFont)}\n label={\n <>\n Trocar para{' '}\n <span style={{ fontFamily: '\"Comic Sans MS\", cursive' }}>\n Comic Sans MS\n </span>\n </>\n }\n />\n </SubControl>\n\n <SubControl label=\"Tamanho da fonte\">\n <PreviewSegmented\n ariaLabel=\"Tamanho da fonte\"\n value={fontSize}\n options={levelOptions.map((opt) => ({\n ...opt,\n preview: (\n <span\n className={cn(\n 'font-semibold leading-none',\n FONT_SIZE_PREVIEW_CLASSES[opt.value]\n )}\n >\n Aa\n </span>\n ),\n }))}\n onChange={setFontSize}\n />\n </SubControl>\n\n <SubControl label=\"Espaçamento entre letras\">\n <PreviewSegmented\n ariaLabel=\"Espaçamento entre letras\"\n value={letterSpacing}\n options={levelOptions.map((opt) => ({\n ...opt,\n preview: (\n <span\n className={cn(\n // Mesmo tamanho do \"Aa\" no botão Padrão de \"Tamanho\n // da fonte\" (text-base), pra que essa scale visualize\n // só a variação de letter-spacing.\n 'text-base font-semibold leading-none',\n LETTER_SPACING_PREVIEW_CLASSES[opt.value]\n )}\n >\n Aa\n </span>\n ),\n }))}\n onChange={setLetterSpacing}\n />\n </SubControl>\n\n <SubControl label=\"Espaçamento entre linhas\">\n <PreviewSegmented\n ariaLabel=\"Espaçamento entre linhas\"\n value={lineSpacing}\n options={levelOptions.map((opt) => ({\n ...opt,\n preview: (\n <span\n className={cn(\n 'flex flex-col items-stretch',\n LINE_SPACING_PREVIEW_GAPS[opt.value]\n )}\n >\n <span className=\"h-0.5 w-7 rounded-full bg-current\" />\n <span className=\"h-0.5 w-7 rounded-full bg-current\" />\n <span className=\"h-0.5 w-7 rounded-full bg-current\" />\n </span>\n ),\n }))}\n onChange={setLineSpacing}\n />\n </SubControl>\n\n <SubControl label=\"Auxiliar de leitura\">\n <Segmented\n ariaLabel=\"Auxiliar de leitura\"\n value={readingAid}\n options={READING_AID_OPTIONS}\n onChange={setReadingAid}\n />\n </SubControl>\n </AccordionSection>\n\n <AccordionSection\n title=\"Leitor de texto\"\n icon={<SpeakerHighIcon size={18} />}\n testId=\"a11y-section-tts\"\n >\n <TTSSection PreviewSegmented={PreviewSegmented} />\n </AccordionSection>\n\n <AccordionSection\n title=\"Animação\"\n icon={<FilmReelIcon size={18} />}\n testId=\"a11y-section-animation\"\n >\n <AccessibilityToggleRow\n label=\"Pausar animações para conforto visual\"\n checked={pauseAnimations}\n onChange={() => setPauseAnimations(!pauseAnimations)}\n switchTestId=\"a11y-toggle-pause-animations\"\n />\n </AccordionSection>\n\n <AccordionSection\n title=\"Navegação\"\n icon={<NavigationArrowIcon size={18} />}\n testId=\"a11y-section-navigation\"\n >\n <SubControl label=\"Destacar links e botões\">\n <AccessibilityToggleRow\n label=\"Adicionar contorno em elementos clicáveis\"\n checked={highlightLinks}\n onChange={() => setHighlightLinks(!highlightLinks)}\n switchTestId=\"a11y-toggle-highlight-links\"\n />\n </SubControl>\n <SubControl label=\"Cursor maior\">\n <AccessibilityToggleRow\n label=\"Aumentar e escurecer cursor\"\n checked={bigCursor}\n onChange={() => setBigCursor(!bigCursor)}\n switchTestId=\"a11y-toggle-big-cursor\"\n />\n </SubControl>\n </AccordionSection>\n\n <AccordionSection\n title=\"Atalho de teclado\"\n icon={<KeyboardIcon size={18} />}\n testId=\"a11y-section-shortcut\"\n >\n <AccessibilityToggleRow\n label=\"Alt+A para abrir o painel\"\n checked={keyboardShortcut}\n onChange={() => setKeyboardShortcut(!keyboardShortcut)}\n switchTestId=\"a11y-toggle-keyboard-shortcut\"\n />\n </AccordionSection>\n </div>\n\n <footer className=\"border-t border-background-200 px-4 py-3\">\n <Button\n variant=\"outline\"\n action=\"primary\"\n size=\"medium\"\n iconLeft={\n <ArrowCounterClockwiseIcon\n size={16}\n weight=\"bold\"\n aria-hidden=\"true\"\n />\n }\n onClick={resetPreferences}\n data-testid=\"a11y-reset\"\n className=\"w-full justify-center\"\n >\n Redefinir ajustes\n </Button>\n </footer>\n </dialog>\n );\n}\n","import type { ReactNode } from 'react';\nimport Text from '../Text/Text';\nimport ToggleSwitch from '../ToggleSwitch/ToggleSwitch';\nimport { cn } from '../../utils/utils';\n\nexport interface AccessibilityToggleRowProps {\n /** Conteúdo principal da linha (texto ou JSX, ex.: trecho em Comic Sans). */\n label: ReactNode;\n /** Aria-label do row quando `label` é JSX e não pode ser usado como texto. */\n ariaLabel?: string;\n /** Texto auxiliar abaixo do label. */\n description?: string;\n checked: boolean;\n onChange: () => void;\n /** Testid no wrapper interativo (toda a linha). */\n rowTestId?: string;\n /** Testid no `<ToggleSwitch>` interno — usado pelos testes que ainda\n * clicam diretamente no botão de switch. */\n switchTestId?: string;\n}\n\n/**\n * Linha clicável \"label à esquerda + ToggleSwitch à direita\" usada nos\n * accordions de Animação, Navegação, Atalho, dislexia e leitor de texto.\n *\n * O wrapper inteiro responde a click/Enter/Space; o `ToggleSwitch` interno\n * é apenas decorativo (`tabIndex={-1}`, `aria-hidden`) pra que toda a área\n * vire alvo de clique sem dupla-ativação do switch.\n */\nexport default function AccessibilityToggleRow({\n label,\n ariaLabel,\n description,\n checked,\n onChange,\n rowTestId,\n switchTestId,\n}: Readonly<AccessibilityToggleRowProps>) {\n const computedAriaLabel =\n ariaLabel ?? (typeof label === 'string' ? label : undefined);\n return (\n <div\n role=\"switch\"\n tabIndex={0}\n aria-checked={checked}\n aria-label={computedAriaLabel}\n data-testid={rowTestId}\n className={cn(\n 'flex items-center justify-between gap-3 rounded-md py-1.5',\n 'cursor-pointer transition-colors duration-150 hover:bg-background-100',\n 'focus:outline-none focus-visible:ring-2 focus-visible:ring-info-500'\n )}\n onClick={onChange}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n onChange();\n }\n }}\n >\n <div className=\"flex flex-col\">\n <Text size=\"sm\" className=\"text-text-900\">\n {label}\n </Text>\n {description && (\n <Text size=\"2xs\" className=\"text-text-600\">\n {description}\n </Text>\n )}\n </div>\n <ToggleSwitch\n checked={checked}\n onChange={() => undefined}\n checkedColor=\"bg-info-600\"\n data-testid={switchTestId}\n tabIndex={-1}\n aria-hidden\n />\n </div>\n );\n}\n","import { useMemo, type ReactElement, type ReactNode } from 'react';\nimport { PauseIcon, PlayIcon, StopIcon } from '@phosphor-icons/react';\nimport Button from '../Button/Button';\nimport Text from '../Text/Text';\nimport Select, {\n SelectTrigger,\n SelectContent,\n SelectItem,\n SelectValue,\n} from '../Select/Select';\nimport AccessibilityToggleRow from './AccessibilityToggleRow';\nimport { useAccessibilityStore } from '../../store/accessibilityStore';\nimport { useTTS } from '../../hooks/useTTS';\n\n/**\n * Opções de velocidade da fala. O `preview` mostra o valor numérico\n * dentro do botão (1.0, 1.25, etc.) e o `label` aparece como caption\n * abaixo. Valores escolhidos para casarem com o que o Web Speech API\n * aceita (0.5–2.0) — `0.75` foi mantido para \"Lento\" em vez de algo\n * mais agressivo porque vozes nativas ficam ininteligíveis abaixo disso.\n */\nconst RATE_OPTIONS: {\n value: string;\n label: string;\n preview: ReactNode;\n}[] = [\n { value: '0.75', label: 'Lento', preview: '0.75' },\n { value: '1', label: 'Normal', preview: '1.0' },\n { value: '1.25', label: 'Rápido', preview: '1.25' },\n { value: '2', label: 'Muito rápido', preview: '2.0' },\n];\n\nexport interface TTSSectionProps {\n /** Componente de segmented com preview visual + label abaixo */\n PreviewSegmented: <T extends string | number>(props: {\n value: T;\n options: { value: T; label: string; preview: ReactNode }[];\n ariaLabel: string;\n onChange: (value: T) => void;\n }) => ReactElement;\n}\n\n/**\n * Bloco de controles do leitor de texto dentro do painel.\n * Estrutura segue o design do Figma:\n * 1. \"Texto para voz\" — toggle que ativa/desativa o modo click-to-read\n * 2. \"Voz\" — seletor de voz\n * 3. \"Velocidade\" — PreviewSegmented com valor numérico\n */\nexport default function TTSSection({\n PreviewSegmented,\n}: Readonly<TTSSectionProps>) {\n const ttsMode = useAccessibilityStore((s) => s.ttsMode);\n const ttsStatus = useAccessibilityStore((s) => s.ttsStatus);\n const ttsRate = useAccessibilityStore((s) => s.ttsRate);\n const ttsVoiceId = useAccessibilityStore((s) => s.ttsVoiceId);\n const setTTSMode = useAccessibilityStore((s) => s.setTTSMode);\n const setTTSRate = useAccessibilityStore((s) => s.setTTSRate);\n const setTTSVoiceId = useAccessibilityStore((s) => s.setTTSVoiceId);\n\n const { isSupported, voices, hasPortugueseVoice, pause, resume, stop } =\n useTTS();\n\n // Preferimos vozes em português; demais ficam ao final.\n const sortedVoices = useMemo(() => {\n return [...voices].sort((a, b) => {\n const aPt = a.lang.toLowerCase().startsWith('pt') ? 0 : 1;\n const bPt = b.lang.toLowerCase().startsWith('pt') ? 0 : 1;\n if (aPt !== bPt) return aPt - bPt;\n return a.name.localeCompare(b.name);\n });\n }, [voices]);\n\n if (!isSupported) {\n return (\n <Text size=\"2xs\" className=\"text-text-600\">\n Síntese de voz não está disponível neste navegador.\n </Text>\n );\n }\n\n const isClickToReadOn = ttsMode === 'click-to-read';\n\n return (\n <div className=\"flex flex-col gap-6\">\n {/* Texto para voz: toggle row */}\n <div className=\"flex flex-col gap-2\">\n <Text\n size=\"xs\"\n weight=\"medium\"\n className=\"uppercase leading-none text-text-600\"\n >\n Texto para voz\n </Text>\n <AccessibilityToggleRow\n label=\"Ative a leitura e clique no texto para ouvir\"\n rowTestId=\"a11y-tts-mode-toggle\"\n checked={isClickToReadOn}\n onChange={() => setTTSMode(isClickToReadOn ? 'off' : 'click-to-read')}\n />\n </div>\n\n {!hasPortugueseVoice && (\n <Text size=\"2xs\" className=\"text-warning-700\">\n Nenhuma voz em português foi encontrada no seu sistema. A leitura será\n feita com a voz padrão disponível.\n </Text>\n )}\n\n {/* Voz: pill select */}\n <div className=\"flex flex-col gap-2\">\n <Text\n size=\"xs\"\n weight=\"medium\"\n className=\"uppercase leading-none text-text-600\"\n >\n Voz\n </Text>\n <Select\n size=\"small\"\n value={ttsVoiceId ?? ''}\n onValueChange={(value) => setTTSVoiceId(value || null)}\n >\n <SelectTrigger\n variant=\"rounded\"\n aria-label=\"Voz da síntese de fala\"\n data-testid=\"a11y-tts-voice-select\"\n >\n <SelectValue placeholder=\"Padrão do navegador\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"\">Padrão do navegador</SelectItem>\n {sortedVoices.map((v) => (\n <SelectItem key={v.id} value={v.id}>\n {v.name} — {v.lang}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Velocidade: preview segmented com valor numérico no botão */}\n <div className=\"flex flex-col gap-2\">\n <Text\n size=\"xs\"\n weight=\"medium\"\n className=\"uppercase leading-none text-text-600\"\n >\n Velocidade\n </Text>\n <PreviewSegmented\n ariaLabel=\"Velocidade da fala\"\n value={String(ttsRate)}\n options={RATE_OPTIONS.map((opt) => ({\n value: opt.value,\n label: opt.label,\n preview: (\n <span className=\"text-base font-bold leading-none\">\n {opt.preview}\n </span>\n ),\n }))}\n onChange={(v) => setTTSRate(Number(v))}\n />\n </div>\n\n {/* Ações: pause/resume/stop conforme status — só visíveis durante a fala */}\n {ttsStatus !== 'idle' && (\n <div className=\"flex items-center gap-2\">\n {ttsStatus === 'speaking' && (\n <Button\n variant=\"outline\"\n action=\"secondary\"\n size=\"small\"\n iconLeft={\n <PauseIcon size={14} weight=\"bold\" aria-hidden=\"true\" />\n }\n onClick={pause}\n data-testid=\"a11y-tts-pause\"\n >\n Pausar\n </Button>\n )}\n\n {ttsStatus === 'paused' && (\n <Button\n variant=\"outline\"\n action=\"secondary\"\n size=\"small\"\n iconLeft={<PlayIcon size={14} weight=\"bold\" aria-hidden=\"true\" />}\n onClick={resume}\n data-testid=\"a11y-tts-resume\"\n >\n Retomar\n </Button>\n )}\n\n <Button\n variant=\"outline\"\n action=\"negative\"\n size=\"small\"\n iconLeft={<StopIcon size={14} weight=\"bold\" aria-hidden=\"true\" />}\n onClick={stop}\n data-testid=\"a11y-tts-stop\"\n className=\"ml-auto\"\n >\n Parar\n </Button>\n </div>\n )}\n </div>\n );\n}\n","import { HandWavingIcon } from '@phosphor-icons/react';\nimport Button from '../Button/Button';\nimport { Tooltip } from '../Tooltip/Tooltip';\nimport { cn } from '../../utils/utils';\nimport {\n FAB_POSITION_CLASSES,\n FAB_TOOLTIP_POSITION,\n FAB_VERTICAL_ALIGN_CLASSES,\n type FabPosition,\n type FabVerticalAlign,\n} from './fabPositioning';\n\ntype AccessibilityFabPosition = FabPosition;\ntype AccessibilityFabVerticalAlign = FabVerticalAlign;\n\nexport interface LibrasFabProps {\n /** Click handler — alterna o painel do VLibras (abrir/fechar) */\n onClick: () => void;\n /** Lado da viewport onde o botão fica colado */\n position?: AccessibilityFabPosition;\n /** Alinhamento vertical (default: `below-center`) */\n verticalAlign?: AccessibilityFabVerticalAlign;\n /** Classes extras */\n className?: string;\n}\n\n/**\n * Segundo botão flutuante (FAB), posicionado abaixo do `AccessibilityFab`.\n * Aciona o widget oficial do VLibras (gov.br) que faz tradução automática\n * de português para Libras via avatar 3D.\n *\n * O botão tem visual constante — não mantemos um indicador de estado\n * \"ativo\" porque o painel do VLibras já reflete visualmente se está\n * aberto ou fechado. Cada clique alterna o painel.\n *\n * Mesma identidade visual do FAB principal (azul escuro, quadrado com\n * canto interno arredondado, colado na lateral).\n */\nexport default function LibrasFab({\n onClick,\n position = 'right',\n verticalAlign = 'below-center',\n className,\n}: Readonly<LibrasFabProps>) {\n const label = 'Tradução em Libras';\n\n return (\n <Tooltip\n content={label}\n position={FAB_TOOLTIP_POSITION[position]}\n className={cn(\n 'fixed z-40',\n FAB_VERTICAL_ALIGN_CLASSES[verticalAlign],\n FAB_POSITION_CLASSES[position]\n )}\n >\n <Button\n variant=\"raw\"\n onClick={onClick}\n aria-label={label}\n data-testid=\"libras-fab\"\n className={cn(\n 'a11y-widget-shield',\n FAB_POSITION_CLASSES[position],\n 'flex h-10 w-10 cursor-pointer items-center justify-center',\n // `text-text-50` flipa junto com `bg-info-900`: claro no light,\n // escuro no dark. Como o ícone usa `currentColor`, com `text-white`\n // a mãozinha sumiria no tema escuro (branco em fundo azul-claro).\n 'bg-info-900 text-text-50 shadow-lg',\n 'transition-all duration-200 hover:scale-110 hover:bg-info-800',\n 'focus:outline-none focus:ring-4 focus:ring-info-300',\n className\n )}\n >\n <HandWavingIcon size={25} weight=\"fill\" aria-hidden=\"true\" />\n </Button>\n </Tooltip>\n );\n}\n","import { useEffect, useState } from 'react';\nimport { useAccessibilityStore } from '../../store/accessibilityStore';\n\nconst RULER_HEIGHT_PX = 32;\nconst MASK_GAP_PX = 80; // metade da altura da janela visível\n\n/**\n * Renderiza auxiliares de leitura (régua ou máscara) que acompanham\n * a posição vertical do mouse. Só monta listener de `mousemove` quando\n * algum auxiliar está ativo, para não pagar custo quando desligado.\n */\nexport default function ReadingAid() {\n const readingAid = useAccessibilityStore((s) => s.readingAid);\n const [mouseY, setMouseY] = useState<number | null>(null);\n\n useEffect(() => {\n if (readingAid === 'none') {\n setMouseY(null);\n return;\n }\n\n let frame = 0;\n const handleMouseMove = (event: MouseEvent) => {\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(() => setMouseY(event.clientY));\n };\n\n document.addEventListener('mousemove', handleMouseMove, { passive: true });\n return () => {\n cancelAnimationFrame(frame);\n document.removeEventListener('mousemove', handleMouseMove);\n };\n }, [readingAid]);\n\n if (readingAid === 'none' || mouseY === null) return null;\n\n if (readingAid === 'ruler') {\n return (\n <div\n aria-hidden=\"true\"\n data-testid=\"a11y-reading-ruler\"\n className=\"a11y-widget-shield pointer-events-none fixed left-0 right-0 z-30\"\n style={{\n top: mouseY - RULER_HEIGHT_PX / 2,\n height: RULER_HEIGHT_PX,\n background: 'rgba(255, 235, 59, 0.25)',\n borderTop: '2px solid #facc15',\n borderBottom: '2px solid #facc15',\n }}\n />\n );\n }\n\n // Máscara: dois retângulos escurecendo acima e abaixo do cursor\n return (\n <>\n <div\n aria-hidden=\"true\"\n data-testid=\"a11y-reading-mask-top\"\n className=\"a11y-widget-shield pointer-events-none fixed left-0 right-0 top-0 z-30 bg-black/90\"\n style={{ height: Math.max(0, mouseY - MASK_GAP_PX) }}\n />\n <div\n aria-hidden=\"true\"\n data-testid=\"a11y-reading-mask-bottom\"\n className=\"a11y-widget-shield pointer-events-none fixed left-0 right-0 bottom-0 z-30 bg-black/90\"\n style={{ top: mouseY + MASK_GAP_PX }}\n />\n </>\n );\n}\n","import {\n ColorBlindMode,\n getColorBlindFilterId,\n} from '../../store/accessibilityStore';\n\n/**\n * SVG escondido com matrizes `<feColorMatrix>` que simulam/auxiliam\n * cada tipo de daltonismo. As classes em `accessibility.css` aplicam\n * `filter: url(#a11y-cb-...)` no `<html>` para colorir a página.\n *\n * Os IDs dos filters são derivados do enum `ColorBlindMode` via\n * `getColorBlindFilterId` — mesma fonte da verdade que o hook usa\n * para aplicar a classe.\n *\n * Matrizes baseadas em Brettel/Vienot/Mollon — padrão amplamente\n * utilizado em ferramentas de acessibilidade como AXE, Daltonize e\n * widget oficial do HandTalk.\n */\nexport default function ColorBlindFilters() {\n return (\n <svg\n aria-hidden=\"true\"\n data-testid=\"a11y-colorblind-filters\"\n style={{\n position: 'absolute',\n width: 0,\n height: 0,\n overflow: 'hidden',\n }}\n >\n <defs>\n <filter id={getColorBlindFilterId(ColorBlindMode.Protanopia)}>\n <feColorMatrix\n type=\"matrix\"\n values=\"0.567 0.433 0 0 0\n 0.558 0.442 0 0 0\n 0 0.242 0.758 0 0\n 0 0 0 1 0\"\n />\n </filter>\n <filter id={getColorBlindFilterId(ColorBlindMode.Deuteranopia)}>\n <feColorMatrix\n type=\"matrix\"\n values=\"0.625 0.375 0 0 0\n 0.700 0.300 0 0 0\n 0 0.300 0.7 0 0\n 0 0 0 1 0\"\n />\n </filter>\n <filter id={getColorBlindFilterId(ColorBlindMode.Tritanopia)}>\n <feColorMatrix\n type=\"matrix\"\n values=\"0.95 0.05 0 0 0\n 0 0.433 0.567 0 0\n 0 0.475 0.525 0 0\n 0 0 0 1 0\"\n />\n </filter>\n </defs>\n </svg>\n );\n}\n","import { useEffect } from 'react';\nimport { useAccessibilityStore } from '../../store/accessibilityStore';\nimport { useTTS } from '../../hooks/useTTS';\n\nconst HIGHLIGHT_CLASS = 'a11y-tts-target';\n\n/**\n * Controller invisível que conecta o `ttsMode` do store ao DOM:\n * - `click-to-read`: registra um listener global de clique. Ao clicar\n * em qualquer elemento (exceto o próprio widget), lê seu texto.\n * - `read-selection`: lê o texto atualmente selecionado quando o store\n * sinaliza (via `pendingReadSelection`). UI/atalho disparam isso.\n *\n * Aplica a classe `.a11y-tts-target` no elemento sendo lido para\n * destaque visual; remove ao terminar/parar.\n */\nexport default function TTSController() {\n const ttsMode = useAccessibilityStore((s) => s.ttsMode);\n const ttsStatus = useAccessibilityStore((s) => s.ttsStatus);\n const { speak, stop, isSupported } = useTTS();\n\n // Click-to-read: ouve cliques globais enquanto o modo está ativo\n useEffect(() => {\n if (!isSupported || ttsMode !== 'click-to-read') return;\n\n const handler = (event: MouseEvent) => {\n const initialTarget = event.target as HTMLElement | null;\n if (!initialTarget) return;\n\n // Ignora cliques dentro do próprio widget\n if (initialTarget.closest('.a11y-widget-shield')) return;\n\n // Sobe pela árvore até achar o ancestral que tem texto legível.\n // Cobre o caso comum de IconButtons com `<svg>` interno: clicar no\n // SVG não retorna texto, mas o botão pai tem aria-label.\n const found = findReadableAncestor(initialTarget);\n if (!found) return;\n\n event.preventDefault();\n event.stopPropagation();\n\n // Limpa highlight anterior e marca o novo alvo\n document\n .querySelectorAll(`.${HIGHLIGHT_CLASS}`)\n .forEach((el) => el.classList.remove(HIGHLIGHT_CLASS));\n found.node.classList.add(HIGHLIGHT_CLASS);\n\n speak(found.text);\n };\n\n // capture: true → pegamos o clique antes do handler do app/link\n document.addEventListener('click', handler, true);\n return () => {\n document.removeEventListener('click', handler, true);\n document\n .querySelectorAll(`.${HIGHLIGHT_CLASS}`)\n .forEach((el) => el.classList.remove(HIGHLIGHT_CLASS));\n // Interrompe qualquer fala em curso ao sair do modo. Sem isso,\n // desligar o \"Clique para ler\" deixaria a leitura terminando\n // sozinha, contrariando a expectativa do usuário.\n stop();\n };\n }, [isSupported, ttsMode, speak, stop]);\n\n // Quando a fala termina (ttsStatus volta a 'idle'), limpa highlight\n useEffect(() => {\n if (ttsStatus === 'idle') {\n document\n .querySelectorAll(`.${HIGHLIGHT_CLASS}`)\n .forEach((el) => el.classList.remove(HIGHLIGHT_CLASS));\n }\n }, [ttsStatus]);\n\n // Para qualquer fala em curso quando o componente desmonta\n useEffect(() => () => stop(), [stop]);\n\n return null;\n}\n\n/**\n * Extrai texto legível do elemento clicado. Prioriza:\n * 1. `aria-label` se presente\n * 2. `title`\n * 3. `innerText` truncado\n *\n * `innerText` (e não `textContent`) respeita estilos como `display: none`\n * e quebras de linha visuais — leitura mais natural.\n */\nconst extractReadableText = (el: HTMLElement): string => {\n const aria = el.getAttribute('aria-label')?.trim();\n if (aria) return aria;\n const title = el.getAttribute('title')?.trim();\n if (title) return title;\n const text = el.innerText?.trim() ?? '';\n // Limita comprimento para evitar leitura infinita ao clicar em\n // contêineres grandes (ex.: <main>). 600 chars ≈ 1min de fala.\n return text.length > 600 ? `${text.slice(0, 600)}...` : text;\n};\n\n/** Quantos níveis de ancestralidade subimos buscando um nó legível. */\nconst MAX_ANCESTOR_LOOKUP = 8;\n\n/**\n * Caminha pela árvore a partir do elemento clicado até achar o ancestral\n * mais próximo que tenha texto legível (`aria-label`, `title` ou\n * `innerText`). Limita a profundidade e nunca retorna o `<body>` para\n * evitar ler a página inteira ao clicar em ícones soltos.\n */\nconst findReadableAncestor = (\n start: HTMLElement\n): { node: HTMLElement; text: string } | null => {\n let node: HTMLElement | null = start;\n let depth = 0;\n while (node && depth < MAX_ANCESTOR_LOOKUP && node !== document.body) {\n const text = extractReadableText(node);\n if (text) return { node, text };\n node = node.parentElement;\n depth++;\n }\n return null;\n};\n","import { useEffect } from 'react';\nimport { useAccessibilityStore } from '../../store/accessibilityStore';\n\nconst SCRIPT_ID = 'a11y-vlibras-script';\nconst SCRIPT_URL = 'https://vlibras.gov.br/app/vlibras-plugin.js';\nconst APP_URL = 'https://vlibras.gov.br/app';\nconst WRAPPER_ID = 'a11y-vlibras-wrapper';\n\ninterface VLibrasGlobal {\n Widget: new (appUrl: string) => unknown;\n}\n\n/**\n * Carrega e ativa o widget oficial de Libras do governo brasileiro\n * (gov.br/vlibras) sob demanda. Estratégia:\n *\n * - Quando `librasEnabled === true`:\n * 1. Injeta o esqueleto DOM esperado pelo VLibras (`<div vw>` etc.)\n * no `<body>` (precisa estar no body, não dentro do React tree).\n * 2. Carrega o script oficial uma única vez (cacheado por id).\n * 3. Instancia `new window.VLibras.Widget(...)` para inicializar o\n * avatar e os assets 3D.\n *\n * - Quando `librasEnabled === false`:\n * - Apenas OCULTA o esqueleto DOM (não remove). O plugin do VLibras\n * registra um listener de `resize` no `window` que ele nunca desanexa;\n * se removêssemos o wrapper, esse listener rodaria `d(\"[vp]\").closest(...)`\n * sobre um `null` (nós já removidos) e lançaria\n * `Cannot read properties of null (reading 'closest')` no próximo resize\n * (rotação de tela / teclado virtual no mobile). Mantendo o esqueleto\n * oculto no DOM, `[vp]`/`[vw]` continuam existindo e o handler órfão não\n * quebra. O script permanece em cache para reativações rápidas.\n *\n * O script é pesado (~MBs de avatares 3D), por isso só carregamos quando\n * o usuário clica para ativar — fluxo opt-in.\n */\nexport default function VLibrasLoader() {\n const enabled = useAccessibilityStore((s) => s.librasEnabled);\n\n useEffect(() => {\n if (typeof document === 'undefined') return;\n\n if (!enabled) {\n hideWrapper();\n return;\n }\n\n injectWrapper();\n loadScript()\n .then(() => {\n const VLibras = (globalThis as unknown as { VLibras?: VLibrasGlobal })\n .VLibras;\n if (!VLibras?.Widget) return;\n\n // ATENÇÃO: o construtor do VLibras Widget registra a lógica de\n // inicialização DENTRO de `window.onload = () => {...}`. Como\n // nesta integração o widget é ativado depois da página já ter\n // carregado, o evento `load` já disparou e o handler nunca\n // executa sozinho. Capturamos o handler que o construtor\n // instalou e chamamos manualmente para popular o DOM (avatar,\n // botão de acesso, painel) imediatamente.\n const previousOnload = globalThis.onload;\n // A instanciação importa pelos efeitos colaterais: o construtor\n // do VLibras registra `window.onload` com a lógica de inicialização\n // do widget. O retorno não é usado e é descartado de propósito.\n new VLibras.Widget(APP_URL); // NOSONAR — instantiation by side effect\n const installed = globalThis.onload;\n if (typeof installed === 'function' && installed !== previousOnload) {\n