{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "accessibility-widget",
  "title": "Accessibility Widget",
  "description": "Floating panel of reading adjustments that apply to the whole document and persist.",
  "dependencies": ["lucide-react"],
  "registryDependencies": [
    "https://indiacn.in/r/theme.json",
    "https://indiacn.in/r/typography.json"
  ],
  "files": [
    {
      "path": "components/ui/accessibility-widget.tsx",
      "content": "'use client';\n\nimport {\n  Accessibility,\n  ALargeSmall,\n  Baseline,\n  Ban,\n  Contrast,\n  Link2,\n  RotateCcw,\n  Space,\n  Type,\n  X,\n} from 'lucide-react';\nimport {\n  ComponentProps,\n  ReactNode,\n  useCallback,\n  useEffect,\n  useState,\n  useSyncExternalStore,\n} from 'react';\n\nimport { Body2, Label1, Label2 } from '@/components/ui/typography';\nimport { cn } from '@/lib/utils';\n\n/*\n * UX4G accessibility widget: a floating button opening a panel of reading\n * adjustments. Every setting is applied to the document root and remembered,\n * so it survives navigation.\n *\n * UX4G also lists a \"Screen Reader\" row. That is deliberately not shipped —\n * a browser-based text-to-speech toy is not a screen reader, and pretending\n * otherwise is worse for the people it claims to serve. Pass `extraRows` if\n * you have a real integration to offer.\n */\nconst STORAGE_KEY = 'indiacn-accessibility';\n\nconst STEP_MAX = 3;\n\ninterface IAccessibilitySettings {\n  textStep: number;\n  lineStep: number;\n  spacingStep: number;\n  highlightLinks: boolean;\n  dyslexiaFont: boolean;\n  hideImages: boolean;\n  invertColors: boolean;\n  readingMask: boolean;\n}\n\nconst DEFAULT_SETTINGS: IAccessibilitySettings = {\n  textStep: 0,\n  lineStep: 0,\n  spacingStep: 0,\n  highlightLinks: false,\n  dyslexiaFont: false,\n  hideImages: false,\n  invertColors: false,\n  readingMask: false,\n};\n\n/*\n * Settings live in a module store read through useSyncExternalStore. Loading\n * them in an effect would set state during render-commit, and it would also\n * let two widgets on one page disagree.\n */\nlet currentSettings: IAccessibilitySettings = DEFAULT_SETTINGS;\nconst LISTENERS = new Set<() => void>();\n\nif (typeof window !== 'undefined') {\n  try {\n    const saved = localStorage.getItem(STORAGE_KEY);\n    if (saved) currentSettings = { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };\n  } catch {\n    // Storage blocked or nothing saved; defaults stand.\n  }\n}\n\n/** Subscribes a widget instance to setting changes. */\nfunction subscribeSettings(listener: () => void) {\n  LISTENERS.add(listener);\n  return () => LISTENERS.delete(listener);\n}\n\n/** Current settings, for useSyncExternalStore. */\nconst getSettings = () => currentSettings;\n\n/** Server render has no storage, so it always sees the defaults. */\nconst getServerSettings = () => DEFAULT_SETTINGS;\n\n/** Replaces the settings and notifies every mounted widget. */\nfunction writeSettings(next: IAccessibilitySettings) {\n  currentSettings = next;\n  applySettings(next);\n  LISTENERS.forEach(listener => listener());\n}\n\n/**\n * Shared access to the reading settings.\n *\n * Exported so other components — the navbar's text-size control, for one — act\n * on the same store instead of keeping a second copy that disagrees with the\n * panel.\n */\nfunction useAccessibilitySettings() {\n  const settings = useSyncExternalStore(subscribeSettings, getSettings, getServerSettings);\n\n  /*\n   * Applied from a mount effect rather than at module scope. Doing it at\n   * module scope tripped a bundler-level temporal dead zone\n   * (\"Cannot access 'k' before initialization\") even with the declarations\n   * ordered correctly, and a module side effect is not worth that fight.\n   *\n   * It lives on the hook, not on the panel, so the navbar's text-size control\n   * works with no panel mounted — which was the original bug.\n   */\n  useEffect(() => {\n    injectBehaviourStyles();\n    applySettings(currentSettings);\n  }, [settings]);\n\n  const setStep = useCallback((key: 'textStep' | 'lineStep' | 'spacingStep', value: number) => {\n    writeSettings({ ...currentSettings, [key]: Math.min(Math.max(value, 0), STEP_MAX) });\n  }, []);\n\n  /*\n   * Reads the store at call time rather than from the render closure. Two\n   * clicks in one tick both saw the same captured step and the second was\n   * lost, so three presses landed on level 2.\n   */\n  const stepBy = useCallback((key: 'textStep' | 'lineStep' | 'spacingStep', delta: number) => {\n    const next = Math.min(Math.max(currentSettings[key] + delta, 0), STEP_MAX);\n    writeSettings({ ...currentSettings, [key]: next });\n  }, []);\n\n  const reset = useCallback(() => writeSettings(DEFAULT_SETTINGS), []);\n\n  return { settings, setStep, stepBy, reset, maxStep: STEP_MAX };\n}\n\n/** Injected once. Registry consumers get the behaviour without extra CSS. */\nconst BEHAVIOUR_CSS = `\n[data-a11y-highlight-links='true'] a {\n  outline: 2px solid currentColor;\n  outline-offset: 2px;\n  text-decoration: underline !important;\n}\n[data-a11y-dyslexia='true'] * {\n  font-family: 'Atkinson Hyperlegible', 'Comic Sans MS', Verdana, sans-serif !important;\n  letter-spacing: 0.05em;\n}\n[data-a11y-hide-images='true'] :is(img, picture, video, svg:not([data-a11y-keep])) {\n  visibility: hidden !important;\n}\n[data-a11y-invert='true'] {\n  filter: invert(1) hue-rotate(180deg);\n}\n[data-a11y-invert='true'] :is(img, picture, video) {\n  filter: invert(1) hue-rotate(180deg);\n}\n.indiacn-a11y-mask {\n  position: fixed;\n  inset-inline: 0;\n  height: 8rem;\n  pointer-events: none;\n  z-index: 2147483000;\n  box-shadow: 0 0 0 100vh rgb(0 0 0 / 0.6);\n}\n@media (prefers-reduced-motion: reduce) {\n  .indiacn-a11y-mask { transition: none; }\n}\n`;\n\n/** Adds the behaviour stylesheet to the document a single time. */\nfunction injectBehaviourStyles() {\n  const id = 'indiacn-a11y-styles';\n  if (document.getElementById(id)) return;\n  const style = document.createElement('style');\n  style.id = id;\n  style.textContent = BEHAVIOUR_CSS;\n  document.head.appendChild(style);\n}\n\n/**\n * Writes settings onto the document root and remembers them.\n *\n * This belongs to the store, not to a component. It used to run in an effect\n * inside the panel, which meant the navbar's text-size control did nothing\n * unless a panel happened to be mounted somewhere on the page.\n */\nfunction applySettings(settings: IAccessibilitySettings) {\n  const root = document.documentElement;\n  root.style.fontSize = settings.textStep ? `${100 + settings.textStep * 12.5}%` : '';\n  root.style.lineHeight = settings.lineStep ? String(1.5 + settings.lineStep * 0.25) : '';\n  root.style.letterSpacing = settings.spacingStep ? `${settings.spacingStep * 0.05}em` : '';\n  root.dataset.a11yHighlightLinks = String(settings.highlightLinks);\n  root.dataset.a11yDyslexia = String(settings.dyslexiaFont);\n  root.dataset.a11yHideImages = String(settings.hideImages);\n  root.dataset.a11yInvert = String(settings.invertColors);\n\n  try {\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));\n  } catch {\n    // Private browsing and blocked storage are fine; the settings just do not persist.\n  }\n}\n\n/** Horizontal reading band that follows the pointer. */\nfunction ReadingMask({ active }: { active: boolean }) {\n  const [top, setTop] = useState(0);\n\n  useEffect(() => {\n    if (!active) return;\n    const move = (e: PointerEvent) => setTop(e.clientY - 64);\n    window.addEventListener('pointermove', move);\n    return () => window.removeEventListener('pointermove', move);\n  }, [active]);\n\n  if (!active) return null;\n  return <div className='indiacn-a11y-mask' style={{ top }} aria-hidden />;\n}\n\ninterface IRowProps {\n  icon: ReactNode;\n  label: string;\n  children: ReactNode;\n}\n\n/** One panel row: icon, label, control. */\nfunction AccessibilityRow({ icon, label, children }: IRowProps) {\n  return (\n    <div className='flex items-center gap-3 border-b border-neutral-100 px-4 py-3 last:border-b-0'>\n      <div className='text-neutral flex size-8 shrink-0 items-center justify-center rounded-md bg-neutral-50 [&>svg]:size-4'>\n        {icon}\n      </div>\n      <Label1 className='text-neutral flex-1'>{label}</Label1>\n      {children}\n    </div>\n  );\n}\n\ninterface IStepperButtonProps {\n  label: string;\n  step: number;\n  onStep: () => void;\n}\n\n/** Cycles a stepped setting and reports where it is. */\nfunction StepControl({ label, step, onStep }: IStepperButtonProps) {\n  return (\n    <button\n      type='button'\n      onClick={onStep}\n      aria-label={`${label}. Level ${step} of ${STEP_MAX}`}\n      className='bg-primary-100 text-primary-800 focus-visible:shadow-focus-primary flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-sm font-semibold transition-opacity hover:opacity-80 focus-visible:outline-none'\n    >\n      {step === 0 ? '+' : step}\n    </button>\n  );\n}\n\ninterface IToggleControlProps {\n  label: string;\n  on: boolean;\n  onToggle: () => void;\n}\n\n/** Small switch used by the boolean rows. */\nfunction ToggleControl({ label, on, onToggle }: IToggleControlProps) {\n  return (\n    <button\n      type='button'\n      role='switch'\n      aria-checked={on}\n      aria-label={label}\n      onClick={onToggle}\n      className={cn(\n        'focus-visible:shadow-focus-primary relative h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors focus-visible:outline-none',\n        on ? 'bg-primary' : 'bg-neutral-200',\n      )}\n    >\n      <div\n        className={cn(\n          'bg-neutral-0 absolute top-1 size-4 rounded-full transition-[left]',\n          on ? 'left-6' : 'left-1',\n        )}\n      />\n    </button>\n  );\n}\n\ninterface IAccessibilityWidgetProps extends Omit<ComponentProps<'div'>, 'children'> {\n  /** Extra rows appended before the footer, for integrations we cannot ship. */\n  extraRows?: ReactNode;\n  title?: string;\n}\n\n/**\n * Floating accessibility panel with reading adjustments.\n *\n * Settings apply to the whole document and persist across pages.\n */\nfunction AccessibilityWidget({\n  className,\n  extraRows,\n  title = 'Accessibility options',\n  ...props\n}: IAccessibilityWidgetProps) {\n  const [open, setOpen] = useState(false);\n  const { settings } = useAccessibilitySettings();\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') setOpen(false);\n    };\n    window.addEventListener('keydown', onKey);\n    return () => window.removeEventListener('keydown', onKey);\n  }, [open]);\n\n  const toggleOpen = useCallback(() => setOpen(o => !o), []);\n  const close = useCallback(() => setOpen(false), []);\n  const reset = useCallback(() => writeSettings(DEFAULT_SETTINGS), []);\n\n  const bump = useCallback(\n    (key: 'textStep' | 'lineStep' | 'spacingStep') =>\n      writeSettings({ ...currentSettings, [key]: (currentSettings[key] + 1) % (STEP_MAX + 1) }),\n    [],\n  );\n  const flip = useCallback(\n    (key: keyof IAccessibilitySettings) =>\n      writeSettings({ ...currentSettings, [key]: !currentSettings[key] }),\n    [],\n  );\n\n  const bumpText = useCallback(() => bump('textStep'), [bump]);\n  const bumpLine = useCallback(() => bump('lineStep'), [bump]);\n  const bumpSpacing = useCallback(() => bump('spacingStep'), [bump]);\n  const flipLinks = useCallback(() => flip('highlightLinks'), [flip]);\n  const flipDyslexia = useCallback(() => flip('dyslexiaFont'), [flip]);\n  const flipImages = useCallback(() => flip('hideImages'), [flip]);\n  const flipInvert = useCallback(() => flip('invertColors'), [flip]);\n  const flipMask = useCallback(() => flip('readingMask'), [flip]);\n\n  return (\n    <div className={cn('fixed right-5 bottom-5 z-50', className)} {...props}>\n      <ReadingMask active={settings.readingMask} />\n\n      {open && (\n        <div\n          role='dialog'\n          aria-label={title}\n          className='bg-neutral-0 absolute right-0 bottom-16 flex max-h-[70vh] w-[340px] flex-col overflow-hidden rounded-lg border border-neutral-100 shadow-lg'\n        >\n          <div className='bg-primary text-primary-foreground flex items-center justify-between px-4 py-3'>\n            <Label1 className='font-semibold'>{title}</Label1>\n            <button\n              type='button'\n              onClick={close}\n              aria-label='Close accessibility options'\n              className='focus-visible:shadow-focus-neutral cursor-pointer rounded-sm opacity-80 transition-opacity hover:opacity-100 focus-visible:outline-none'\n            >\n              <X className='size-5' aria-hidden />\n            </button>\n          </div>\n\n          <div className='min-h-0 flex-1 overflow-y-auto'>\n            <AccessibilityRow icon={<ALargeSmall />} label='Increase text size'>\n              <StepControl label='Increase text size' step={settings.textStep} onStep={bumpText} />\n            </AccessibilityRow>\n            <AccessibilityRow icon={<Baseline />} label='Increase line height'>\n              <StepControl\n                label='Increase line height'\n                step={settings.lineStep}\n                onStep={bumpLine}\n              />\n            </AccessibilityRow>\n            <AccessibilityRow icon={<Space />} label='Increase text spacing'>\n              <StepControl\n                label='Increase text spacing'\n                step={settings.spacingStep}\n                onStep={bumpSpacing}\n              />\n            </AccessibilityRow>\n            <AccessibilityRow icon={<Link2 />} label='Highlight links'>\n              <ToggleControl\n                label='Highlight links'\n                on={settings.highlightLinks}\n                onToggle={flipLinks}\n              />\n            </AccessibilityRow>\n            <AccessibilityRow icon={<Type />} label='Dyslexia friendly'>\n              <ToggleControl\n                label='Dyslexia friendly'\n                on={settings.dyslexiaFont}\n                onToggle={flipDyslexia}\n              />\n            </AccessibilityRow>\n            <AccessibilityRow icon={<Ban />} label='Hide images'>\n              <ToggleControl label='Hide images' on={settings.hideImages} onToggle={flipImages} />\n            </AccessibilityRow>\n            <AccessibilityRow icon={<Contrast />} label='Invert colours'>\n              <ToggleControl\n                label='Invert colours'\n                on={settings.invertColors}\n                onToggle={flipInvert}\n              />\n            </AccessibilityRow>\n            <AccessibilityRow icon={<Accessibility />} label='Reading mask'>\n              <ToggleControl label='Reading mask' on={settings.readingMask} onToggle={flipMask} />\n            </AccessibilityRow>\n            {extraRows}\n          </div>\n\n          <div className='flex items-center justify-between border-t border-neutral-100 px-4 py-3'>\n            <button\n              type='button'\n              onClick={reset}\n              aria-label='Reset accessibility settings'\n              className='bg-primary-100 text-primary-800 focus-visible:shadow-focus-primary inline-flex cursor-pointer items-center gap-1.5 rounded-md px-3 py-1.5 transition-opacity hover:opacity-80 focus-visible:outline-none'\n            >\n              <RotateCcw className='size-3.5' aria-hidden />\n              <Label2>Reset</Label2>\n            </button>\n            <Body2 className='text-neutral-500'>Settings are remembered</Body2>\n          </div>\n        </div>\n      )}\n\n      <button\n        type='button'\n        onClick={toggleOpen}\n        aria-expanded={open}\n        aria-label={title}\n        className='bg-primary text-primary-foreground focus-visible:shadow-focus-primary flex size-14 cursor-pointer items-center justify-center rounded-full shadow-lg transition-transform hover:scale-105 focus-visible:outline-none'\n      >\n        <Accessibility className='size-7' aria-hidden />\n      </button>\n    </div>\n  );\n}\n\nexport type { IAccessibilitySettings };\n\nexport { AccessibilityWidget, useAccessibilitySettings };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}
