# functions

id: dropzone_ts_ui
name: dropzone
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Dropzone(props: DropzoneProps): JSX.Element
tags: ["dropzone","upload","drag-drop","file","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/dropzone"]
tests: []
file_path: frontend/functions/ui/dropzone.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onDrop","onReject"]
framework: react
variant: []
content_hash: 9f4890f617db6d59f3329aa9a888c0a062106978255e1c7b3fca029478d955fc
description:
  Zona de drag-and-drop para archivos con estados idle/accept/reject, límite de tamaño y tipos MIME. Wrapper sobre Mantine Dropzone.

example:
  ```tsx
  import {
    Dropzone,
    DropzoneAccept,
    DropzoneReject,
    DropzoneIdle,
    IMAGE_MIME_TYPE,
  } from '@fn_library/dropzone'
  import { IconPhoto, IconUpload, IconX } from '@tabler/icons-react'
  import { Group, Text } from '@mantine/core'

  function ImageUploader() {
    return (
      <Dropzone
        onDrop={(files) => console.log('Archivos aceptados:', files)}
        onReject={(files) => console.log('Archivos rechazados:', files)}
        accept={IMAGE_MIME_TYPE}
        maxSize={5 * 1024 ** 2}
      >
        <Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
          <DropzoneAccept>
            <IconUpload size={52} stroke={1.5} />
          </DropzoneAccept>
          <DropzoneReject>
            <IconX size={52} stroke={1.5} />
          </DropzoneReject>
          <DropzoneIdle>
            <IconPhoto size={52} stroke={1.5} />
          </DropzoneIdle>
          <div>
            <Text size="xl" inline>
              Arrastra imágenes aquí o haz clic para seleccionar
            </Text>
            <Text size="sm" c="dimmed" inline mt={7}>
              Máximo 5 MB por imagen
            </Text>
          </div>
        </Group>
      </Dropzone>
    )
  }
  ```

props:
  [{"name":"onDrop","type":"(files: File[]) =\u003e void","required":false,"description":"Callback ejecutado cuando el usuario suelta archivos aceptados"},{"name":"onReject","type":"(files: FileRejection[]) =\u003e void","required":false,"description":"Callback ejecutado cuando el usuario suelta archivos rechazados (tipo o tamaño inválido)"},{"name":"accept","type":"Record\u003cstring, string[]\u003e","required":false,"description":"Tipos MIME aceptados. Usar IMAGE_MIME_TYPE o MIME_TYPES para constantes predefinidas"},{"name":"maxSize","type":"number","required":false,"description":"Tamaño máximo de archivo en bytes"},{"name":"multiple","type":"boolean","required":false,"description":"Permite seleccionar múltiples archivos a la vez"},{"name":"loading","type":"boolean","required":false,"description":"Muestra estado de carga y desactiva la interacción"},{"name":"disabled","type":"boolean","required":false,"description":"Desactiva el dropzone"},{"name":"children","type":"React.ReactNode","required":true,"description":"Contenido interno, generalmente compuesto con DropzoneAccept, DropzoneReject y DropzoneIdle"}]

notes:
  El prop `onDrop` tiene un default vacío (`() => {}`) para que el componente sea válido sin handler. Siempre sobreescribirlo en uso real.

  Sub-componentes exportados:
  - `DropzoneAccept` — visible cuando el archivo arrastrado es aceptado (tipo y tamaño válidos)
  - `DropzoneReject` — visible cuando el archivo es rechazado
  - `DropzoneIdle` — visible en estado de reposo
  - `DropzoneFullScreen` — captura drops en cualquier parte de la pantalla

  Constantes de tipos MIME exportadas:
  - `IMAGE_MIME_TYPE` — imágenes comunes (png, jpg, gif, webp, etc.)
  - `MIME_TYPES` — objeto con claves por tipo (pdf, csv, xlsx, mp4, etc.)

code:
  import { Dropzone as MantineDropzone, IMAGE_MIME_TYPE, MIME_TYPES } from '@mantine/dropzone'
  import type { DropzoneProps as MantineDropzoneProps } from '@mantine/dropzone'
  import '@mantine/dropzone/styles.css'

  interface DropzoneProps extends Partial<MantineDropzoneProps> {
    children: React.ReactNode
  }

  function Dropzone({ children, ...props }: DropzoneProps) {
    return <MantineDropzone onDrop={() => {}} {...props}>{children}</MantineDropzone>
  }

  // Re-export sub-components and constants
  const DropzoneAccept = MantineDropzone.Accept
  const DropzoneReject = MantineDropzone.Reject
  const DropzoneIdle = MantineDropzone.Idle
  const DropzoneFullScreen = MantineDropzone.FullScreen

  export { Dropzone, DropzoneAccept, DropzoneReject, DropzoneIdle, DropzoneFullScreen, IMAGE_MIME_TYPE, MIME_TYPES }
  export type { DropzoneProps }

params_schema:
  {"output":"Componente Dropzone que renderiza área de arrastrar y soltar archivos con feedback visual"}


id: empty_state_ts_ui
name: empty_state
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: EmptyState(props: EmptyStateProps): JSX.Element
tags: ["empty-state","placeholder","no-data","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/empty_state.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onAction"]
framework: react
variant: []
content_hash: 0631ae3f509271461151c97b0caedf4ae7cc6ce163c599630ee6d45ba72bc48e
description:
  Placeholder para listas y tablas vacías con icono, título, descripción y acción opcional. Tabler Icons por defecto.

example:
  ```tsx
  import { EmptyState } from '@fn_library/empty_state'

  // Default — sin datos
  <EmptyState />

  // Con acción
  <EmptyState
    title="No functions found"
    description="Try adjusting your search or create a new function."
    actionLabel="Create function"
    onAction={() => navigate('/new')}
  />

  // Con icono custom
  import { IconDatabase } from '@tabler/icons-react'

  <EmptyState
    icon={<IconDatabase size={48} stroke={1.5} />}
    title="No databases connected"
    description="Connect a database to start querying data."
    size="lg"
  />

  // Dentro de una Card
  import { Card } from '@mantine/core'
  import { EmptyState } from '@fn_library/empty_state'

  <Card withBorder p="xl">
    <EmptyState
      title="No results"
      description="Your query returned no rows."
      size="sm"
    />
  </Card>
  ```

props:
  [{"name":"icon","type":"React.ReactNode","required":false,"description":"Icono a mostrar. Default: IconInbox de @tabler/icons-react."},{"name":"title","type":"string","required":false,"description":"Título del empty state. Default: 'No data found'."},{"name":"description","type":"string","required":false,"description":"Descripción explicativa. Default: 'There are no items to display yet.'."},{"name":"actionLabel","type":"string","required":false,"description":"Texto del botón de acción. Se muestra solo si también hay onAction."},{"name":"onAction","type":"() =\u003e void","required":false,"description":"Callback del botón de acción. Se muestra solo si también hay actionLabel."},{"name":"size","type":"MantineSize","required":false,"description":"Tamaño general del componente. Afecta el icono, texto y botón. Default: 'md'."},{"name":"children","type":"React.ReactNode","required":false,"description":"Contenido custom renderizado debajo de la descripción y antes del botón."}]

notes:
  El tamaño del icono escala con `size`: xs=32, sm=40, md=48, lg=64, xl=80.
  El orden del heading (`Title order`) es 5 para xs/sm y 4 para md/lg/xl.
  El botón usa `variant="light"` de Mantine — hereda el color primario del tema.
  `children` se renderiza entre la descripción y el botón, útil para filtros o links adicionales.

code:
  import * as React from 'react'
  import { Button, Stack, Text, Title, type MantineSize } from '@mantine/core'
  import { IconInbox } from '@tabler/icons-react'

  interface EmptyStateProps {
    /** Icono a mostrar (default: IconInbox) */
    icon?: React.ReactNode
    /** Título */
    title?: string
    /** Descripción */
    description?: string
    /** Texto del botón de acción */
    actionLabel?: string
    /** Callback del botón */
    onAction?: () => void
    /** Tamaño general */
    size?: MantineSize
    /** Contenido custom debajo de la descripción */
    children?: React.ReactNode
  }

  function EmptyState({
    icon,
    title = 'No data found',
    description = 'There are no items to display yet.',
    actionLabel,
    onAction,
    size = 'md',
    children,
  }: EmptyStateProps) {
    const iconSize = size === 'xs' ? 32 : size === 'sm' ? 40 : size === 'lg' ? 64 : size === 'xl' ? 80 : 48

    return (
      <Stack align="center" gap="sm" py="xl">
        <Text c="dimmed" style={{ opacity: 0.5 }}>
          {icon || <IconInbox size={iconSize} stroke={1.5} />}
        </Text>
        <Title order={size === 'xs' || size === 'sm' ? 5 : 4} ta="center">
          {title}
        </Title>
        <Text c="dimmed" size={size} ta="center" maw={400}>
          {description}
        </Text>
        {children}
        {actionLabel && onAction && (
          <Button variant="light" size={size} onClick={onAction} mt="xs">
            {actionLabel}
          </Button>
        )}
      </Stack>
    )
  }

  export { EmptyState }
  export type { EmptyStateProps }

params_schema:
  {"params":[{"name":"props","desc":"Props del componente EmptyState"}],"output":"Componente EmptyState centrado con icono, mensaje y botón de acción para estados sin datos"}


id: error_page_ts_ui
name: error_page
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ErrorPage(config: ErrorPageConfig): JSX.Element
tags: ["error","404","500","page","empty-state","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/error_page.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:43:09Z
props: []
emits: ["onAction"]
framework: react
variant: []
content_hash: ebd12cccde155e575d2a8db82c2e95c65b59f5832b6963408c97298ab881be33
description:
  Genera página de error con código grande, título, descripción y acciones. Soporta 404, 500, 403 y cualquier código custom.

example:
  ```tsx
  import { ErrorPage } from '@fn_library/error_page'
  import { Button } from '@mantine/core'

  // 404 con defaults
  <ErrorPage onAction={() => navigate('/')} />

  // 500 custom
  <ErrorPage
    code={500}
    title="Internal Server Error"
    description="Something went wrong on our end. Please try again later or contact support."
    actionLabel="Retry"
    onAction={() => window.location.reload()}
  />

  // 403 con acciones extra
  <ErrorPage
    code={403}
    title="Access Denied"
    description="You don't have permission to view this page. Contact your administrator to request access."
    actionLabel="Go to Dashboard"
    onAction={() => navigate('/dashboard')}
    extraActions={
      <Button variant="outline" size="md" onClick={() => navigate('/login')}>
        Switch Account
      </Button>
    }
  />
  ```

notes:
  El código de error se muestra con `fz={120}` y opacidad reducida (0.25) para crear un efecto visual de fondo sin distraer del mensaje. Acepta `number | string` para soportar códigos custom como "503" o "Maintenance".

code:
  import * as React from 'react'
  import { Button, Container, Group, Stack, Text, Title } from '@mantine/core'

  interface ErrorPageConfig {
    /** Código de error (404, 500, 403, etc.) */
    code?: number | string
    /** Título del error */
    title?: string
    /** Descripción del error */
    description?: string
    /** Texto del botón de acción */
    actionLabel?: string
    /** Callback del botón */
    onAction?: () => void
    /** Acciones extra además del botón principal */
    extraActions?: React.ReactNode
  }

  function ErrorPage({
    code = 404,
    title = 'Page not found',
    description = 'The page you are looking for does not exist. You may have mistyped the address, or the page has been moved to another URL.',
    actionLabel = 'Go back to home',
    onAction,
    extraActions,
  }: ErrorPageConfig) {
    return (
      <Container py={80}>
        <Stack align="center" gap="md">
          <Text
            fz={120}
            fw={900}
            c="dimmed"
            style={{ lineHeight: 1, opacity: 0.25 }}
          >
            {code}
          </Text>
          <Title order={2} ta="center">
            {title}
          </Title>
          <Text c="dimmed" size="lg" ta="center" maw={500}>
            {description}
          </Text>
          <Group mt="md">
            <Button size="md" onClick={onAction}>
              {actionLabel}
            </Button>
            {extraActions}
          </Group>
        </Stack>
      </Container>
    )
  }

  export { ErrorPage }
  export type { ErrorPageConfig }

params_schema:
  {"params":[{"name":"code","desc":"Código de error numérico o string a mostrar prominentemente (404, 500, 403, o cualquier valor custom)"},{"name":"title","desc":"Título del error mostrado bajo el código. Default: 'Page not found'"},{"name":"description","desc":"Descripción explicativa del error. Default: mensaje genérico de página no encontrada"},{"name":"actionLabel","desc":"Texto del botón de acción principal. Default: 'Go back to home'"},{"name":"onAction","desc":"Callback invocado al pulsar el botón de acción principal"},{"name":"extraActions","desc":"Nodos React adicionales renderizados junto al botón principal (botones secundarios, links, etc.)"}],"output":"Página de error centrada con código prominente, mensaje descriptivo y botones de acción"}


id: file_input_ts_ui
name: file_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FileInput(props: FileInputProps): JSX.Element
tags: ["file","upload","input","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/file_input.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onChange"]
framework: react
variant: []
content_hash: 21a2914e0ea648a59f5097e7ce24349839b9ad86b5e4f0e98adc04cc77d7e186
description:
  Input de archivos con soporte para múltiples archivos, tipos aceptados y botón de limpiar. Wrapper sobre Mantine FileInput.

example:
  ```tsx
  import { FileInput } from '@fn_library'

  // Archivo único
  <FileInput
    label="Subir documento"
    placeholder="Selecciona un archivo"
    clearable
  />

  // Múltiples archivos
  <FileInput
    label="Subir archivos"
    placeholder="Selecciona uno o varios archivos"
    multiple
    clearable
  />

  // Solo imágenes
  <FileInput
    label="Subir imagen"
    placeholder="Selecciona una imagen"
    accept="image/*"
    clearable
  />
  ```

props:
  [{"name":"multiple","type":"boolean","required":false,"description":"Permite seleccionar múltiples archivos"},{"name":"accept","type":"string","required":false,"description":"Tipos MIME o extensiones aceptadas (ej: 'image/*', '.pdf,.docx')"},{"name":"clearable","type":"boolean","required":false,"description":"Muestra botón para limpiar el archivo seleccionado"},{"name":"value","type":"File | File[] | null","required":false,"description":"Valor controlado del input"},{"name":"onChange","type":"(value: File | File[] | null) =\u003e void","required":false,"description":"Callback que se dispara al seleccionar o limpiar un archivo"},{"name":"placeholder","type":"string","required":false,"description":"Texto mostrado cuando no hay archivo seleccionado"},{"name":"label","type":"string","required":false,"description":"Etiqueta visible sobre el input"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  Wrapper directo sobre `FileInput` de `@mantine/core`. Acepta todas las props de Mantine sin restricciones.

  Para `multiple: true`, el tipo de `value` y `onChange` cambia a `File[] | null` automáticamente gracias al tipado genérico de Mantine.

  El prop `clearable` añade un ícono de X que permite vaciar la selección sin reabrir el explorador de archivos.

code:
  import { FileInput as MantineFileInput, type FileInputProps as MantineFileInputProps } from '@mantine/core'

  interface FileInputProps extends MantineFileInputProps {}

  function FileInput(props: FileInputProps) {
    return <MantineFileInput {...props} />
  }

  export { FileInput }
  export type { FileInputProps }

params_schema:
  {"params":[{"name":"props","desc":"Props de FileInput: archivo(s) seleccionado(s), tipos aceptados, modo múltiple, estado controlado y apariencia"}],"output":"Componente FileInput que renderiza input para selección de archivos con preview del nombre"}


id: form_field_ts_ui
name: form_field
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FormField(props: FormFieldProps): JSX.Element
tags: ["form","field","label","error","component","ui","accessibility"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/form_field.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["default"]
content_hash: a69d4fc1004c567770aa66f0276945fb6e3cae21962ef397bd8fda05a953e5df
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/form-field.tsx
description:
  Wrapper de campo de formulario con label, helper text, error y ARIA automáticos. Inyecta id y aria-describedby a hijos.

example:
  ```tsx
  <FormField label="Email" helperText="Tu email corporativo" error={errors.email}>
    <Input type="email" />
  </FormField>
  ```

props:
  [{"name":"label","type":"string","required":false,"description":"Texto del label"},{"name":"helperText","type":"string","required":false,"description":"Texto de ayuda"},{"name":"error","type":"string","required":false,"description":"Mensaje de error (reemplaza helperText)"},{"name":"children","type":"ReactNode","required":true,"description":"Input o componente de formulario"}]

code:
  import * as React from 'react'
  import { Box, Text } from '@mantine/core'

  interface FormFieldProps {
    label?: string
    helperText?: string
    error?: string
    children: React.ReactNode
    className?: string
  }

  function FormField({ label, helperText, error, children, className }: FormFieldProps) {
    const id = React.useId()
    const inputId = `${id}-input`
    const helperId = `${id}-helper`
    const errorId = `${id}-error`

    const describedBy = [helperText ? helperId : null, error ? errorId : null].filter(Boolean).join(' ') || undefined

    const childWithProps = React.Children.map(children, (child) => {
      if (React.isValidElement(child)) {
        return React.cloneElement(child as React.ReactElement<Record<string, unknown>>, {
          id: inputId,
          'aria-invalid': error ? true : undefined,
          'aria-describedby': describedBy,
          error: error || undefined,
        })
      }
      return child
    })

    return (
      <Box className={className}>
        {label && (
          <Text component="label" htmlFor={inputId} size="sm" fw={500} mb={4} display="block">
            {label}
          </Text>
        )}
        {childWithProps}
        {helperText && !error && (
          <Text id={helperId} size="sm" c="dimmed" mt={4}>
            {helperText}
          </Text>
        )}
        {error && (
          <Text id={errorId} size="sm" c="red" mt={4}>
            {error}
          </Text>
        )}
      </Box>
    )
  }

  export { FormField }
  export type { FormFieldProps }

params_schema:
  {"output":"Componente FormField que renderiza wrapper de campo con label, helper text, error y ARIA automáticos"}


id: graph_container_ts_ui
name: graph_container
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: GraphContainer(props: GraphContainerProps): JSX.Element
description: Interactive graph visualization with sigma.js, graphology, and ForceAtlas2 layout
tags: ["component","ui","graph","visualization","sigma","graphology","network"]
uses_functions: []
uses_types: []
returns: []
imports: ["graphology","graphology-layout-forceatlas2","sigma"]
tests: []
file_path: frontend/functions/ui/graph/index.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T21:31:43Z
emits: []
framework: react
variant: []
content_hash: cb9cbaa58b4181c4fe040b6ed32b8deed1198d2e4bed149c2f11560c7f231868
example:
  ```tsx
  import { GraphContainer } from '@fn_library/graph'
  import type { GraphData } from '@fn_library/graph'

  const data: GraphData = {
    nodes: [
      { id: '1', label: 'Node A', color: '#e74c3c', size: 10 },
      { id: '2', label: 'Node B', color: '#3498db', size: 8 },
    ],
    edges: [
      { id: 'e1', source: '1', target: '2', label: 'connects', type: 'arrow' },
    ],
  }

  function MyGraph() {
    return (
      <GraphContainer
        data={data}
        layout="organic"
        showLegend
        nodeTypes={[
          { type: 'person', color: '#e74c3c', label: 'Person' },
          { type: 'org', color: '#3498db', label: 'Organization' },
        ]}
        onNodeClick={(node) => console.log('clicked:', node.id)}
        height="500px"
      />
    )
  }
  ```

props:
  [{"name":"data","type":"GraphData","required":true,"description":"Graph data with nodes and edges arrays"},{"name":"layout","type":"'organic' | 'random'","required":false,"description":"Layout algorithm (default: organic/ForceAtlas2)"},{"name":"showLegend","type":"boolean","required":false,"description":"Show node type legend overlay"},{"name":"nodeTypes","type":"NodeType[]","required":false,"description":"Node type definitions for legend"},{"name":"onNodeClick","type":"(node: GraphNode) =\u003e void","required":false,"description":"Node click handler"},{"name":"onNodeDoubleClick","type":"(node: GraphNode) =\u003e void","required":false,"description":"Node double-click handler"},{"name":"theme","type":"GraphTheme","required":false,"description":"Visual theme overrides"},{"name":"height","type":"string | number","required":false,"description":"Container height (default: 100%)"},{"name":"className","type":"string","required":false,"description":"Additional CSS classes"}]

notes:
  - Usa graphology como estructura de datos de grafo
  - ForceAtlas2 para layout organico (iterations adaptativas segun numero de nodos)
  - Sigma.js para renderizado WebGL de alto rendimiento
  - Soporta grafos dirigidos multi-edge
  - El componente limpia la instancia Sigma al desmontar

code:
  import * as React from "react"
  import Graph from "graphology"
  import forceAtlas2 from "graphology-layout-forceatlas2"
  import { Sigma } from "sigma"

  // ── Types ─────────────────────────────────────────────────────────────────

  export interface GraphNode {
    id: string
    label: string
    type?: string
    color?: string
    size?: number
    x?: number
    y?: number
    [key: string]: unknown
  }

  export interface GraphEdge {
    id: string
    source: string
    target: string
    label?: string
    color?: string
    size?: number
    type?: "arrow" | "line"
    weight?: number
    [key: string]: unknown
  }

  export interface GraphData {
    nodes: GraphNode[]
    edges: GraphEdge[]
  }

  export interface NodeType {
    type: string
    color: string
    label: string
  }

  export interface GraphTheme {
    backgroundColor?: string
    nodeColor?: string
    nodeSize?: number
    edgeColor?: string
    edgeSize?: number
    labelColor?: string
    selectionColor?: string
  }

  export interface ContextMenuTarget {
    type: "node" | "edge" | "canvas"
    id?: string
    data?: GraphNode | GraphEdge
  }

  export interface GraphContainerProps {
    data: GraphData
    layout?: "organic" | "random"
    showToolbar?: boolean
    showLegend?: boolean
    showMinimap?: boolean
    nodeTypes?: NodeType[]
    onNodeClick?: (node: GraphNode) => void
    onNodeDoubleClick?: (node: GraphNode) => void
    onContextMenu?: (event: MouseEvent, target: ContextMenuTarget) => void
    enableSelection?: boolean
    selectionMode?: "single" | "multiple"
    theme?: GraphTheme
    height?: string | number
    className?: string
  }

  const DEFAULT_THEME: Required<GraphTheme> = {
    backgroundColor: "var(--background, #0a0a0f)",
    nodeColor: "#95a5a6",
    nodeSize: 8,
    edgeColor: "rgba(255,255,255,0.19)",
    edgeSize: 1,
    labelColor: "#e0e0e0",
    selectionColor: "#3b82f6",
  }

  // ── Component ─────────────────────────────────────────────────────────────

  function GraphContainer({
    data,
    layout = "organic",
    showLegend = false,
    nodeTypes = [],
    onNodeClick,
    onNodeDoubleClick,
    onContextMenu,
    theme: themeProp,
    height = "100%",
    className,
  }: GraphContainerProps) {
    const containerRef = React.useRef<HTMLDivElement>(null)
    const sigmaRef = React.useRef<Sigma | null>(null)
    const graphRef = React.useRef<Graph | null>(null)
    const theme = React.useMemo(
      () => ({ ...DEFAULT_THEME, ...themeProp }),
      [themeProp],
    )

    // Build + render — wait for container to have dimensions
    const [ready, setReady] = React.useState(false)
    React.useEffect(() => {
      const el = containerRef.current
      if (!el) return
      if (el.clientHeight > 0 && el.clientWidth > 0) {
        setReady(true)
        return
      }
      const ro = new ResizeObserver((entries) => {
        for (const entry of entries) {
          if (entry.contentRect.height > 0 && entry.contentRect.width > 0) {
            setReady(true)
            ro.disconnect()
          }
        }
      })
      ro.observe(el)
      return () => ro.disconnect()
    }, [])

    React.useEffect(() => {
      const el = containerRef.current
      if (!el || !ready) return

      // Cleanup previous instance
      if (sigmaRef.current) {
        sigmaRef.current.kill()
        sigmaRef.current = null
      }

      const g = new Graph({ multi: true, type: "directed" })
      graphRef.current = g

      // Add nodes — store entity type as entityType to avoid sigma interpreting it as render program
      for (const n of data.nodes) {
        g.addNode(n.id, {
          label: n.label,
          x: n.x ?? (Math.random() - 0.5) * 10,
          y: n.y ?? (Math.random() - 0.5) * 10,
          size: n.size ?? theme.nodeSize,
          color: n.color ?? theme.nodeColor,
          entityType: n.type,
        })
      }

      // Add edges
      for (const e of data.edges) {
        try {
          g.addEdgeWithKey(e.id, e.source, e.target, {
            label: e.label,
            size: e.size ?? theme.edgeSize,
            color: e.color ?? theme.edgeColor,
            type: e.type === "arrow" ? "arrow" : "line",
            weight: e.weight ?? 1,
          })
        } catch {
          // skip duplicate keys
        }
      }

      // Layout
      if (layout === "organic" && g.order > 0) {
        forceAtlas2.assign(g, {
          iterations: Math.min(500, Math.max(100, g.order * 5)),
          settings: {
            gravity: 1,
            scalingRatio: 2,
            slowDown: 5,
            barnesHutOptimize: g.order > 300,
          },
        })
      }

      // Render
      const renderer = new Sigma(g, el, {
        allowInvalidContainer: true,
        renderEdgeLabels: false,
        defaultEdgeColor: theme.edgeColor,
        defaultNodeColor: theme.nodeColor,
        labelColor: { color: theme.labelColor },
        labelSize: 11,
      })

      sigmaRef.current = renderer

      // Events
      if (onNodeClick) {
        renderer.on("clickNode", ({ node }) => {
          const attrs = g.getNodeAttributes(node)
          onNodeClick({ id: node, ...attrs } as unknown as GraphNode)
        })
      }
      if (onNodeDoubleClick) {
        renderer.on("doubleClickNode", ({ node }) => {
          const attrs = g.getNodeAttributes(node)
          onNodeDoubleClick({ id: node, ...attrs } as unknown as GraphNode)
        })
      }
      if (onContextMenu) {
        renderer.on("rightClickNode", ({ node, event }) => {
          const mouseEvent = event.original as MouseEvent
          mouseEvent.preventDefault()
          const attrs = g.getNodeAttributes(node)
          onContextMenu(mouseEvent, {
            type: "node",
            id: node,
            data: { id: node, ...attrs } as unknown as GraphNode,
          })
        })
        renderer.on("rightClickStage", ({ event }) => {
          const mouseEvent = event.original as MouseEvent
          mouseEvent.preventDefault()
          onContextMenu(mouseEvent, { type: "canvas" })
        })
      }

      return () => {
        renderer.kill()
        sigmaRef.current = null
        graphRef.current = null
      }
    }, [data, layout, theme, onNodeClick, onNodeDoubleClick, onContextMenu, ready])

    // Container background
    const containerStyle: React.CSSProperties = {
      height,
      width: "100%",
      position: "relative",
      background: theme.backgroundColor,
      borderRadius: "var(--radius, 0.5rem)",
      overflow: "hidden",
    }

    return (
      <div className={className} style={containerStyle}>
        <div ref={containerRef} style={{ width: "100%", height: "100%" }} />
        {showLegend && nodeTypes.length > 0 && (
          <div
            style={{
              position: "absolute",
              top: 12,
              right: 12,
              background: "rgba(0,0,0,0.7)",
              backdropFilter: "blur(6px)",
              borderRadius: 8,
              padding: "10px 14px",
              fontSize: 12,
              display: "flex",
              flexDirection: "column",
              gap: 6,
            }}
          >
            {nodeTypes.map((nt) => (
              <div
                key={nt.type}
                style={{ display: "flex", alignItems: "center", gap: 8 }}
              >
                <span
                  style={{
                    width: 10,
                    height: 10,
                    borderRadius: "50%",
                    background: nt.color,
                    flexShrink: 0,
                  }}
                />
                <span style={{ color: theme.labelColor }}>{nt.label}</span>
              </div>
            ))}
          </div>
        )}
      </div>
    )
  }

  export { GraphContainer }

params_schema:
  {"output":"Componente GraphContainer que renderiza grafo interactivo con sigma.js, ForceAtlas2 layout y legend"}


id: indicator_ts_ui
name: indicator
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnIndicator(props: FnIndicatorProps): JSX.Element
description: Badge indicador posicionado sobre un elemento hijo. Wrapper sobre Mantine Indicator.
tags: ["mantine","indicator","badge","notification","dot","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/indicator.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: b70cfbbec3d0679a403c26ca26aabf3926ea11ac92161525b7307f5c66b6fcee
params_schema: {"output":"Elemento hijo con un dot/badge indicador posicionado en una esquina"}
example:
  ```tsx
  import { FnIndicator } from '@fn_library'
  import { FnActionIcon } from '@fn_library'
  import { IconBell } from '@tabler/icons-react'

  {/* Dot simple */}
  <FnIndicator processing>
    <FnActionIcon icon={<IconBell size={18} />} />
  </FnIndicator>

  {/* Con contador */}
  <FnIndicator label={5} size={16} color="blue">
    <Avatar src="user.png" />
  </FnIndicator>
  ```

props:
  [{"name":"children","type":"ReactNode","required":true,"description":"Elemento sobre el cual se posiciona el indicador"},{"name":"color","type":"MantineColor","required":false,"description":"Color del indicador, default red"},{"name":"size","type":"number","required":false,"description":"Tamano del dot en px, default 10"},{"name":"position","type":"'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'","required":false,"description":"Posicion del indicador, default top-end"},{"name":"processing","type":"boolean","required":false,"description":"Animacion de pulso, default false"},{"name":"disabled","type":"boolean","required":false,"description":"Oculta el indicador, default false"},{"name":"label","type":"ReactNode","required":false,"description":"Contenido dentro del indicador (numero, texto)"}]

notes:
  Wrapper sobre Mantine `Indicator`. El `processing` prop agrega una animacion de pulso al dot. Si se provee `label`, el indicador se agranda para mostrar contenido. `disabled` oculta el indicador sin desmontar el componente.

code:
  import * as React from 'react'
  import { Indicator } from '@mantine/core'
  import type { MantineColor } from '@mantine/core'

  interface FnIndicatorProps {
    children: React.ReactNode
    color?: MantineColor
    size?: number
    position?: 'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'
    processing?: boolean
    disabled?: boolean
    label?: React.ReactNode
  }

  function FnIndicator({
    children,
    color = 'red',
    size = 10,
    position = 'top-end',
    processing = false,
    disabled = false,
    label,
  }: FnIndicatorProps) {
    return (
      <Indicator
        color={color}
        size={size}
        position={position}
        processing={processing}
        disabled={disabled}
        label={label}
      >
        {children}
      </Indicator>
    )
  }

  export { FnIndicator }
  export type { FnIndicatorProps }


id: input_ts_ui
name: input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Input(props: InputHTMLAttributes): JSX.Element
tags: ["input","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/input.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onChange","onFocus","onBlur"]
framework: react
variant: ["default"]
content_hash: e816180a4058744dd3e8e5f618f6f5187213f7555d34b5cdfa98b64913df6714
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/input.tsx
description:
  Campo de entrada accesible con soporte para iconos, grupos, validación ARIA y estados disabled/invalid. Mantine TextInput.

example:
  ```tsx
  <Input placeholder="Email" type="email" />
  <InputGroup>
    <InputIcon position="start"><SearchIcon /></InputIcon>
    <Input placeholder="Buscar..." />
  </InputGroup>
  ```

props:
  [{"name":"type","type":"string","required":false,"description":"Tipo de input HTML"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Exporta Input, InputGroup e InputIcon. Usa Mantine TextInput internamente. InputGroup e InputIcon se mantienen como wrappers de compatibilidad — para nuevos usos preferir leftSection/rightSection de Mantine TextInput directamente.

code:
  import * as React from 'react'
  import { TextInput, Box } from '@mantine/core'

  function Input({
    className,
    type,
    ...props
  }: React.ComponentProps<typeof TextInput> & { type?: string }) {
    return (
      <TextInput
        type={type}
        data-slot="input"
        size="sm"
        className={className}
        {...props}
      />
    )
  }

  interface InputGroupProps {
    children: React.ReactNode
    className?: string
  }

  function InputGroup({ children, className }: InputGroupProps) {
    return (
      <Box data-slot="input-group" className={className}>
        {children}
      </Box>
    )
  }

  interface InputIconProps {
    children: React.ReactNode
    position: 'start' | 'end'
    className?: string
  }

  function InputIcon({ children, position, className }: InputIconProps) {
    return (
      <Box
        data-slot={`input-icon-${position}`}
        component="span"
        className={className}
        style={{
          display: 'inline-flex',
          alignItems: 'center',
          pointerEvents: 'none',
        }}
      >
        {children}
      </Box>
    )
  }

  export { Input, InputGroup, InputIcon }
  export type { InputGroupProps, InputIconProps }

params_schema:
  {"output":"Componente Input que renderiza campo de entrada accesible con soporte para iconos y validación ARIA"}


id: kpi_card_ts_ui
name: kpi_card
kind: component
lang: ts
domain: ui
version: 2.0.0
purity: impure
signature: KPICard(props: KPICardProps): JSX.Element
tags: ["kpi","card","metrics","dashboard","component","ui","sparkline"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/kpi_card.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["sm","default","lg"]
content_hash: a329e3426e037a5ff266298d4f24b2a791e4c6fc397e6ef0430cb37b1f648293
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/kpi-card.tsx
description:
  Card de KPI con label, valor+unidad, delta descriptivo con color semántico, icono, slot de chart inline y action. 3 tamaños.

example:
  ```tsx
  import { KPICard, Sparkline } from '@fn_library'

  {/* Básico */}
  <KPICard label="Revenue" value="$12,450" delta={{ value: 12.5, isPositive: true }} />

  {/* Con unidad separada, delta descriptivo, y mini barras */}
  <KPICard
    label="Processed Prompts"
    value="124"
    unit="k"
    icon={<ZapIcon className="h-4 w-4" />}
    delta={{ value: 15, isPositive: true, label: "Prompts Increased by", suffix: "vs yesterday" }}
    chart={<Sparkline data={[5, 8, 3, 9, 6, 12, 7]} variant="bar" colors={['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#06b6d4']} height={32} />}
    action={<button className="text-muted-foreground hover:text-foreground">...</button>}
  />

  {/* Dashboard dark sin bordes */}
  <KPICard label="Sessions" value={9821} className="border-0 shadow-none" />
  ```

props:
  [{"name":"label","type":"string","required":true,"description":"Etiqueta del KPI"},{"name":"value","type":"string | number","required":true,"description":"Valor principal"},{"name":"unit","type":"string","required":false,"description":"Unidad junto al valor en font menor (ej: k, ms, %)"},{"name":"delta","type":"{ value: number; isPositive: boolean; label?: string; suffix?: string }","required":false,"description":"Cambio con dirección, label descriptivo y sufijo"},{"name":"icon","type":"ReactNode","required":false,"description":"Icono a la izquierda del label"},{"name":"action","type":"ReactNode","required":false,"description":"Slot top-right para menú o acciones"},{"name":"chart","type":"ReactNode","required":false,"description":"Slot para mini chart inline junto al valor"},{"name":"size","type":"'sm' | 'default' | 'lg'","required":false,"description":"Tamaño"}]

notes:
  - El icono ahora se renderiza a la **izquierda** del label (antes estaba a la derecha).
  - `unit` separa la unidad del valor con font menor para el efecto "124 k" del diseño.
  - `delta.label` y `delta.suffix` permiten texto descriptivo: "Increased by ▲ +15% vs yesterday".
  - `chart` es un slot genérico — pasar un `<Sparkline variant="bar" colors={[...]} />` para mini barras multicolor.
  - `action` es un slot top-right para menú contextual.
  - Usa `cn()` para merge de clases. `className="border-0 shadow-none"` para dashboards dark.

code:
  import * as React from 'react'
  import { Paper, Text, Group, Stack, Box } from '@mantine/core'

  type KPICardSize = 'sm' | 'default' | 'lg'

  interface Delta {
    value: number
    isPositive: boolean
    label?: string
    suffix?: string
  }

  interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
    label: string
    value: string | number
    unit?: string
    delta?: Delta
    icon?: React.ReactNode
    action?: React.ReactNode
    chart?: React.ReactNode
    subtitle?: string
    size?: KPICardSize
  }

  const valueSizes: Record<KPICardSize, string> = {
    sm: '1.5rem',
    default: '1.875rem',
    lg: '2.25rem',
  }

  const unitSizes: Record<KPICardSize, string> = {
    sm: 'md',
    default: 'lg',
    lg: 'xl',
  }

  const labelSizes: Record<KPICardSize, string> = {
    sm: 'xs',
    default: 'sm',
    lg: 'md',
  }

  const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
    ({ label, value, unit, delta, icon, action, chart, subtitle, size = 'default', className, ...props }, ref) => {
      const deltaColor = delta
        ? delta.value === 0 ? 'dimmed'
          : delta.isPositive ? 'teal'
          : 'red'
        : undefined

      return (
        <Paper ref={ref} withBorder shadow="xs" radius="md" p="md" className={className} {...props}>
          <Group justify="space-between" align="flex-start">
            <Group gap="xs" align="center">
              {icon && <Box c="dimmed">{icon}</Box>}
              <Stack gap={2}>
                <Text size={labelSizes[size]} c="dimmed">{label}</Text>
                {subtitle && <Text size="xs" c="dimmed" opacity={0.8}>{subtitle}</Text>}
              </Stack>
            </Group>
            {action && <Box c="dimmed">{action}</Box>}
          </Group>

          <Group justify="space-between" align="flex-end" mt="md" gap="lg">
            <Stack gap={4}>
              <Group gap={4} align="baseline">
                <Text fw={700} style={{ fontSize: valueSizes[size], lineHeight: 1, letterSpacing: '-0.025em' }}>
                  {value}
                </Text>
                {unit && <Text size={unitSizes[size]} c="dimmed" fw={500}>{unit}</Text>}
              </Group>
              {delta && (
                <Group gap={4} align="center">
                  {delta.label && <Text size="xs" c="dimmed">{delta.label}</Text>}
                  <Text size="xs" fw={500} c={deltaColor}>
                    {delta.isPositive ? '\u25B2' : '\u25BC'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'}
                  </Text>
                  {delta.suffix && <Text size="xs" c="dimmed">{delta.suffix}</Text>}
                </Group>
              )}
            </Stack>
            {chart && <Box style={{ flexShrink: 0 }}>{chart}</Box>}
          </Group>
        </Paper>
      )
    }
  )
  KPICard.displayName = 'KPICard'

  export { KPICard }
  export type { KPICardProps, Delta, KPICardSize }

params_schema:
  {"output":"Componente KPICard que renderiza card de métrica con label, valor, delta descriptivo, icono y slot de mini chart"}


id: label_ts_ui
name: label
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Label(props: LabelHTMLAttributes): JSX.Element
tags: ["label","form","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
example:
  ```tsx
  <Label htmlFor="email">Email</Label>
  ```

tests: []
file_path: frontend/functions/ui/label.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
props: [{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]
emits: []
framework: react
variant: ["default"]
content_hash: 802fbfef5f66113c50c30c5f84ee531469f077deea2a9a605306e478375b64de
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/label.tsx
description:
  Etiqueta de formulario accesible con soporte para estados disabled. Mantine Text con component=label.

code:
  import * as React from 'react'
  import { Text } from '@mantine/core'

  function Label({ className, ...props }: React.ComponentProps<'label'>) {
    return (
      <Text
        component="label"
        data-slot="label"
        size="sm"
        fw={500}
        style={{ display: 'inline-flex', alignItems: 'center', gap: 8, userSelect: 'none' }}
        className={className}
        {...props}
      />
    )
  }

  export { Label }

params_schema:
  {"output":"Componente Label que renderiza etiqueta de formulario accesible con soporte para estados disabled"}


id: line_chart_ts_ui
name: line_chart
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: LineChart(props: LineChartProps): JSX.Element
tags: ["chart","line","visualization","mantine","component","ui"]
uses_functions: ["chart_container_ts_ui"]
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/charts","@mantine/core"]
tests: []
file_path: frontend/functions/ui/line_chart.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default"]
content_hash: f669922808b5972ca951e6b47e454c8439b0ba91f8ad3c278596f4db11aa5e23
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/line-chart.tsx
description:
  Gráfico de líneas @mantine/charts con multi-series, 5 tipos de curva, líneas de referencia y tooltips.

example:
  ```tsx
  <LineChart
    data={salesData}
    xKey="month"
    series={[
      { key: "revenue", name: "Revenue", color: "#3b82f6" },
      { key: "cost", name: "Cost", color: "#ef4444" },
    ]}
    zoomable
    showLegend
  />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Array de datos"},{"name":"xKey","type":"string","required":true,"description":"Key del eje X"},{"name":"series","type":"Series[]","required":false,"description":"Series de datos"},{"name":"curveType","type":"'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'","required":false,"description":"Tipo de curva (default monotone)"},{"name":"referenceLines","type":"Array\u003c{ y: number; label?: string; color?: string }\u003e","required":false,"description":"Líneas de referencia horizontales"}]

code:
  import { LineChart as MantineLineChart } from '@mantine/charts'
  import { Paper } from '@mantine/core'
  import { type Series, getSeriesColor } from './chart_container'

  type CurveType = 'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'

  interface LineChartProps {
    data: Record<string, unknown>[]
    xKey: string
    yKey?: string
    series?: Series[]
    curveType?: CurveType
    showGrid?: boolean
    showLegend?: boolean
    showDots?: boolean
    height?: number
    xAxisFormatter?: (value: unknown) => string
    yAxisFormatter?: (value: unknown) => string
    valueFormatter?: (value: number) => string
    referenceLines?: Array<{ y: number; label?: string; color?: string }>
  }

  function LineChartComponent({
    data, xKey, yKey, series, curveType = 'monotone', showGrid = true, showLegend = false,
    showDots = true, height = 300, xAxisFormatter, yAxisFormatter,
    valueFormatter = (v) => v.toLocaleString(), referenceLines = [],
  }: LineChartProps) {
    const chartSeries = series
      ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
      : yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []

    const refLines = referenceLines.map((ref) => ({
      y: ref.y,
      label: ref.label || '',
      color: ref.color || 'gray.6',
    }))

    return (
      <Paper p="md">
        <MantineLineChart
          h={height}
          data={data}
          dataKey={xKey}
          series={chartSeries}
          curveType={curveType}
          gridAxis={showGrid ? 'xy' : 'none'}
          withLegend={showLegend}
          withTooltip
          withDots={showDots}
          valueFormatter={valueFormatter}
          referenceLines={refLines}
          xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
          yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
        />
      </Paper>
    )
  }

  export const LineChart = LineChartComponent
  export type { LineChartProps, CurveType }

params_schema:
  {"output":"Componente LineChart que renderiza gráfico de líneas multi-series con curvas customizables y líneas de referencia"}


id: loading_overlay_ts_ui
name: loading_overlay
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnLoadingOverlay(props: FnLoadingOverlayProps): JSX.Element
description: Overlay de carga con blur y opacidad configurable. Wrapper sobre Mantine LoadingOverlay.
tags: ["mantine","loading","overlay","spinner","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/loading_overlay.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: 12ef844c4f0c972651c0a7dc25f7a668de9edacfe5901af7b28b69cf7ac46246
params_schema: {"output":"Overlay semi-transparente con spinner centrado que cubre el contenedor padre"}
example:
  ```tsx
  import { FnLoadingOverlay } from '@fn_library'

  <Box pos="relative">
    <FnLoadingOverlay visible={loading} overlayBlur={3} />
    <DataTable data={rows} />
  </Box>
  ```

props:
  [{"name":"visible","type":"boolean","required":true,"description":"Controla visibilidad del overlay"},{"name":"loaderSize","type":"number | string","required":false,"description":"Tamano del loader/spinner"},{"name":"overlayBlur","type":"number","required":false,"description":"Intensidad del blur del fondo, default 2"},{"name":"overlayOpacity","type":"number","required":false,"description":"Opacidad del overlay, default 0.5"}]

notes:
  Wrapper sobre Mantine `LoadingOverlay`. El contenedor padre necesita `position: relative` para que el overlay se posicione correctamente. Usa `loaderProps` y `overlayProps` internamente para mapear las props simplificadas.

code:
  import { LoadingOverlay } from '@mantine/core'

  interface FnLoadingOverlayProps {
    visible: boolean
    loaderSize?: number | string
    overlayBlur?: number
    overlayOpacity?: number
  }

  function FnLoadingOverlay({
    visible,
    loaderSize,
    overlayBlur = 2,
    overlayOpacity = 0.5,
  }: FnLoadingOverlayProps) {
    return (
      <LoadingOverlay
        visible={visible}
        loaderProps={loaderSize ? { size: loaderSize } : undefined}
        overlayProps={{ blur: overlayBlur, backgroundOpacity: overlayOpacity }}
      />
    )
  }

  export { FnLoadingOverlay }
  export type { FnLoadingOverlayProps }


id: mantine_provider_ts_ui
name: mantine_provider
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnMantineProvider({ children, theme?, defaultColorScheme? })
tags: ["mantine","provider","theme","react"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["@mantine/core","@mantine/notifications","@mantine/charts"]
tests: []
file_path: frontend/functions/ui/mantine_provider.tsx
created_at: 2026-04-05T23:51:12Z
updated_at: 2026-04-05T23:51:12Z
emits: []
framework: react
variant: []
content_hash: 9c22c3fa629b9109a8c9ffd57869b77875d260937886188d6ef9b7a0b3de43eb
params_schema: {"output":"arbol React envuelto en MantineProvider con notificaciones"}
description:
  Provider raiz de Mantine para apps del registry. Wrappea MantineProvider con Notifications incluido. Importa los CSS de @mantine/core, charts y notifications.

example:
  ```tsx
  import { createTheme, MantineColorsTuple } from '@mantine/core'
  import { FnMantineProvider } from '@fn_library'

  const brand: MantineColorsTuple = [
    '#e5f3ff', '#cde2ff', '#9ac2ff', '#64a0ff', '#3884fe',
    '#1d72fe', '#0063ff', '#0058e4', '#004ecd', '#0043b5'
  ]

  const theme = createTheme({
    colors: { brand },
    primaryColor: 'brand',
  })

  function App() {
    return (
      <FnMantineProvider theme={theme} defaultColorScheme="dark">
        {/* Tu app aqui */}
      </FnMantineProvider>
    )
  }
  ```

props:
  [{"name":"children","type":"","required":false,"description":""},{"name":"theme","type":"","required":false,"description":""},{"name":"defaultColorScheme","type":"","required":false,"description":""}]

notes:
  Reemplaza ThemeProvider + applyTheme del sistema anterior. Las apps definen su propio tema con `createTheme()` y lo pasan como prop. Los CSS de Mantine se importan una sola vez aqui.

code:
  import '@mantine/core/styles.css'
  import '@mantine/charts/styles.css'
  import '@mantine/notifications/styles.css'

  import * as React from 'react'
  import { MantineProvider, type MantineThemeOverride, type MantineColorScheme } from '@mantine/core'
  import { Notifications } from '@mantine/notifications'

  interface FnMantineProviderProps {
    children: React.ReactNode
    theme?: MantineThemeOverride
    defaultColorScheme?: MantineColorScheme
  }

  function FnMantineProvider({
    children,
    theme,
    defaultColorScheme = 'dark',
  }: FnMantineProviderProps) {
    return (
      <MantineProvider theme={theme} defaultColorScheme={defaultColorScheme}>
        <Notifications position="top-right" />
        {children}
      </MantineProvider>
    )
  }

  export { FnMantineProvider }
  export type { FnMantineProviderProps }


id: multi_select_ts_ui
name: multi_select
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: MultiSelect(props: MultiSelectProps): JSX.Element
description: Selector múltiple con búsqueda, pills y límite de selecciones. Wrapper sobre Mantine MultiSelect.
tags: ["multi-select","form","dropdown","pills","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/multi_select.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["default"]
content_hash: cf6374275f78097c84c0ad2407453a377a47430753f15f1e91249d049ac33d83
params_schema: {"output":"Componente MultiSelect que renderiza dropdown con selección múltiple, búsqueda y pills"}
example:
  ```tsx
  import { MultiSelect } from '@fn_library'

  // Opciones simples
  <MultiSelect
    label="Frameworks favoritos"
    placeholder="Elige uno o más"
    data={['React', 'Vue', 'Angular', 'Svelte']}
  />

  // Con búsqueda y clearable
  <MultiSelect
    label="Tecnologías"
    placeholder="Busca y selecciona"
    searchable
    clearable
    value={selected}
    onChange={setSelected}
    data={[
      { value: 'react', label: 'React' },
      { value: 'vue', label: 'Vue' },
      { value: 'ts', label: 'TypeScript' },
    ]}
  />

  // Máximo de selecciones
  <MultiSelect
    label="Elige hasta 2"
    placeholder="Máximo 2"
    maxValues={2}
    data={['Opción A', 'Opción B', 'Opción C', 'Opción D']}
  />
  ```

props:
  [{"name":"data","type":"string[] | { value: string; label: string; disabled?: boolean }[]","required":true,"description":"Opciones del selector — strings o objetos {value, label}"},{"name":"value","type":"string[]","required":false,"description":"Valores seleccionados (controlled)"},{"name":"onChange","type":"(value: string[]) =\u003e void","required":false,"description":"Callback al cambiar la selección"},{"name":"searchable","type":"boolean","required":false,"description":"Permite buscar entre opciones"},{"name":"clearable","type":"boolean","required":false,"description":"Muestra botón para limpiar toda la selección"},{"name":"maxValues","type":"number","required":false,"description":"Número máximo de valores seleccionables"},{"name":"placeholder","type":"string","required":false,"description":"Texto cuando no hay selección"},{"name":"label","type":"string","required":false,"description":"Label del campo"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilitar el selector"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  - Wrapper directo sobre `MultiSelect` de `@mantine/core` v9. Todas las props de Mantine MultiSelect son válidas.
  - A diferencia de `Select`, `value` es `string[]` y `onChange` recibe `string[]`.
  - Las selecciones se muestran como pills dentro del input, eliminables con clic.
  - `maxValues` limita cuántos items pueden seleccionarse — el dropdown bloquea más selecciones al alcanzar el límite.
  - Soporta `searchable` para filtrar opciones y `clearable` para un botón de limpiar todo.

code:
  import { MultiSelect as MantineMultiSelect, type MultiSelectProps as MantineMultiSelectProps } from '@mantine/core'

  interface MultiSelectProps extends MantineMultiSelectProps {}

  function MultiSelect(props: MultiSelectProps) {
    return <MantineMultiSelect {...props} />
  }

  export { MultiSelect }
  export type { MultiSelectProps }


id: nav_link_ts_ui
name: nav_link
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnNavLink(props: FnNavLinkProps): JSX.Element
description: Link de navegacion con icono, descripcion y anidamiento. Wrapper sobre Mantine NavLink.
tags: ["mantine","navigation","link","sidebar","menu","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/nav_link.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: 30fc234c90a3c0fe60b0da80dd432f92a541c3fb5a9c0270af29f4c47972dcc8
params_schema: {"output":"Link de navegacion con highlight activo, icono y soporte para sub-items colapsables"}
example:
  ```tsx
  import { FnNavLink } from '@fn_library'
  import { IconHome, IconSettings } from '@tabler/icons-react'

  <FnNavLink label="Home" icon={<IconHome size={16} />} active />
  <FnNavLink label="Settings" icon={<IconSettings size={16} />} defaultOpened>
    <FnNavLink label="General" />
    <FnNavLink label="Seguridad" />
  </FnNavLink>
  ```

props:
  [{"name":"label","type":"string","required":true,"description":"Texto principal del link"},{"name":"description","type":"string","required":false,"description":"Texto secundario debajo del label"},{"name":"icon","type":"ReactNode","required":false,"description":"Icono a la izquierda del label"},{"name":"active","type":"boolean","required":false,"description":"Estado activo/seleccionado"},{"name":"onClick","type":"MouseEventHandler","required":false,"description":"Callback al hacer click"},{"name":"href","type":"string","required":false,"description":"URL para navegacion como anchor"},{"name":"children","type":"ReactNode","required":false,"description":"NavLinks hijos para crear arbol anidado"},{"name":"opened","type":"boolean","required":false,"description":"Controla si los hijos estan expandidos"},{"name":"defaultOpened","type":"boolean","required":false,"description":"Estado inicial de expansion de hijos"}]

notes:
  Wrapper sobre Mantine `NavLink`. Soporta anidamiento -- pasar `FnNavLink` como children crea un arbol colapsable. El `icon` se mapea a `leftSection` internamente. Ideal para uso dentro de `FnAppShell` navbar.

code:
  import * as React from 'react'
  import { NavLink } from '@mantine/core'

  interface FnNavLinkProps {
    label: string
    description?: string
    icon?: React.ReactNode
    active?: boolean
    onClick?: React.MouseEventHandler<HTMLButtonElement>
    href?: string
    children?: React.ReactNode
    opened?: boolean
    defaultOpened?: boolean
  }

  function FnNavLink({
    label,
    description,
    icon,
    active,
    onClick,
    href,
    children,
    opened,
    defaultOpened,
  }: FnNavLinkProps) {
    return (
      <NavLink
        label={label}
        description={description}
        leftSection={icon}
        active={active}
        onClick={onClick}
        href={href}
        opened={opened}
        defaultOpened={defaultOpened}
      >
        {children}
      </NavLink>
    )
  }

  export { FnNavLink }
  export type { FnNavLinkProps }


id: number_input_ts_ui
name: number_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnNumberInput(props: FnNumberInputProps): JSX.Element
description: Input numerico con min/max, step, prefijo y sufijo. Wrapper sobre Mantine NumberInput.
tags: ["mantine","input","number","form","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/number_input.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: e81f97e6fb1dd0c80d22863aacd45a43dc04b101dc7c6cf00cc8e1ff475c1bf0
params_schema: {"output":"Input numerico con controles de incremento, validacion y decoradores de texto"}
example:
  ```tsx
  import { FnNumberInput } from '@fn_library'

  <FnNumberInput
    label="Precio"
    value={price}
    onChange={setPrice}
    min={0}
    max={10000}
    step={0.01}
    prefix="$"
    description="Precio unitario en USD"
  />
  ```

props:
  [{"name":"value","type":"number | string","required":false,"description":"Valor actual del input"},{"name":"onChange","type":"(value: number | string) =\u003e void","required":false,"description":"Callback cuando cambia el valor"},{"name":"min","type":"number","required":false,"description":"Valor minimo permitido"},{"name":"max","type":"number","required":false,"description":"Valor maximo permitido"},{"name":"step","type":"number","required":false,"description":"Incremento/decremento por click"},{"name":"label","type":"string","required":false,"description":"Etiqueta del input"},{"name":"description","type":"string","required":false,"description":"Texto descriptivo debajo del input"},{"name":"error","type":"string","required":false,"description":"Mensaje de error"},{"name":"placeholder","type":"string","required":false,"description":"Placeholder del input"},{"name":"prefix","type":"string","required":false,"description":"Texto prefijo dentro del input (ej: $)"},{"name":"suffix","type":"string","required":false,"description":"Texto sufijo dentro del input (ej: kg)"}]

notes:
  Wrapper sobre Mantine `NumberInput`. Soporta prefix/suffix para decorar el valor visualmente. Los controles de incremento/decremento respetan min/max/step.

code:
  import { NumberInput } from '@mantine/core'

  interface FnNumberInputProps {
    value?: number | string
    onChange?: (value: number | string) => void
    min?: number
    max?: number
    step?: number
    label?: string
    description?: string
    error?: string
    placeholder?: string
    prefix?: string
    suffix?: string
  }

  function FnNumberInput({
    value,
    onChange,
    min,
    max,
    step,
    label,
    description,
    error,
    placeholder,
    prefix,
    suffix,
  }: FnNumberInputProps) {
    return (
      <NumberInput
        value={value}
        onChange={onChange}
        min={min}
        max={max}
        step={step}
        label={label}
        description={description}
        error={error}
        placeholder={placeholder}
        prefix={prefix}
        suffix={suffix}
      />
    )
  }

  export { FnNumberInput }
  export type { FnNumberInputProps }


id: page_header_ts_ui
name: page_header
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: PageHeader(props: PageHeaderProps): JSX.Element
tags: ["header","page","layout","navigation","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/page_header.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: ["onBack","onTabChange"]
framework: react
variant: ["full","simple"]
content_hash: e5ebf95efbe269da740b32133b4f81167879d89cbfa1ec090a9f18b6db3e293c
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/page-header.tsx
description:
  Cabecera de página con título, subtítulo, acciones, back button, tabs integrados, badge y modo sticky. Incluye SimplePageHeader.

example:
  ```tsx
  <PageHeader
    title="Dashboard"
    subtitle="Vista general"
    actions={<Button>Export</Button>}
    tabs={[{ label: "Overview", value: "overview" }, { label: "Analytics", value: "analytics" }]}
    activeTab="overview"
    onTabChange={setTab}
  />
  ```

props:
  [{"name":"title","type":"string","required":true,"description":"Título principal"},{"name":"subtitle","type":"string","required":false,"description":"Subtítulo"},{"name":"actions","type":"ReactNode","required":false,"description":"Botones de acción"},{"name":"tabs","type":"TabItem[]","required":false,"description":"Tabs de navegación integrados"},{"name":"sticky","type":"boolean","required":false,"description":"Header fijo al scroll"}]

code:
  "use client"

  import * as React from "react"
  import { Group, Stack, Title, Text, ActionIcon, Tabs, Box } from "@mantine/core"
  import { IconChevronLeft } from "@tabler/icons-react"

  interface TabItem {
    label: string
    value: string
    icon?: React.ReactNode
    disabled?: boolean
  }

  interface PageHeaderProps {
    title: string
    subtitle?: string
    actions?: React.ReactNode
    onBack?: () => void
    tabs?: TabItem[]
    activeTab?: string
    onTabChange?: (value: string) => void
    badge?: React.ReactNode
    sticky?: boolean
  }

  function PageHeader({
    title, subtitle, actions, onBack, tabs, activeTab, onTabChange,
    badge, sticky = false,
  }: PageHeaderProps) {
    return (
      <Box
        data-slot="page-header"
        pb="md"
        style={{
          borderBottom: '1px solid var(--mantine-color-default-border)',
          backgroundColor: 'var(--mantine-color-body)',
          ...(sticky ? { position: 'sticky', top: 0, zIndex: 20 } : {}),
        }}
      >
        <Stack gap="md">
          <Group justify="space-between" align="flex-start" gap="md">
            <Group align="flex-start" gap="sm">
              {onBack && (
                <ActionIcon variant="subtle" size="sm" onClick={onBack} mt={4}>
                  <IconChevronLeft size={16} />
                </ActionIcon>
              )}
              <Stack gap={4}>
                <Group gap="sm" align="center">
                  <Title order={2}>{title}</Title>
                  {badge}
                </Group>
                {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
              </Stack>
            </Group>
            {actions && <Group gap="xs" style={{ flexShrink: 0 }}>{actions}</Group>}
          </Group>
          {tabs && tabs.length > 0 && (
            <Tabs value={activeTab} onChange={(v) => v && onTabChange?.(v)} style={{ marginBottom: 'calc(-1 * var(--mantine-spacing-md))' }}>
              <Tabs.List>
                {tabs.map((tab) => (
                  <Tabs.Tab key={tab.value} value={tab.value} disabled={tab.disabled} leftSection={tab.icon}>
                    {tab.label}
                  </Tabs.Tab>
                ))}
              </Tabs.List>
            </Tabs>
          )}
        </Stack>
      </Box>
    )
  }

  interface SimplePageHeaderProps {
    title: string
    description?: string
    children?: React.ReactNode
  }

  function SimplePageHeader({ title, description, children }: SimplePageHeaderProps) {
    return (
      <Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
        <Stack gap={4}>
          <Title order={2}>{title}</Title>
          {description && <Text size="sm" c="dimmed">{description}</Text>}
        </Stack>
        {children && <Group gap="xs">{children}</Group>}
      </Group>
    )
  }

  export { PageHeader, SimplePageHeader }

params_schema:
  {"output":"Componente PageHeader que renderiza cabecera de página con título, acciones, tabs integrados y modo sticky"}


id: pagination_ts_ui
name: pagination
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Pagination(props: PaginationProps): JSX.Element
description: Controles de navegacion de paginas autocontenido. Mantine Pagination.
tags: ["pagination","navigation","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/pagination.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onChange"]
framework: react
variant: []
content_hash: a8124f26783ce8c042ab0a4bd48654233d426833e004892121dd6277942d25f2
params_schema: {"output":"Componente Pagination autocontenido que renderiza controles de navegacion de paginas"}
example:
  ```tsx
  // Basico
  <Pagination total={10} defaultValue={1} />

  // Controlado
  <Pagination total={20} value={page} onChange={setPage} />

  // Con botones first/last
  <Pagination total={50} withEdges siblings={2} />
  ```

props:
  [{"name":"total","type":"number","required":true,"description":"Numero total de paginas"},{"name":"value","type":"number","required":false,"description":"Pagina actual (controlado)"},{"name":"defaultValue","type":"number","required":false,"description":"Pagina inicial (no controlado)"},{"name":"onChange","type":"(page: number) =\u003e void","required":false,"description":"Callback al cambiar de pagina"},{"name":"siblings","type":"number","required":false,"description":"Paginas visibles a cada lado de la actual"},{"name":"boundaries","type":"number","required":false,"description":"Paginas visibles al inicio y final"},{"name":"withEdges","type":"boolean","required":false,"description":"Mostrar botones first/last page"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Usa Mantine Pagination autocontenido. Previous/Next, numeros de pagina y elipsis se generan automaticamente. La API anterior con sub-componentes (PaginationContent, PaginationItem, PaginationLink, etc.) fue reemplazada por un unico componente con props declarativas. Export: Pagination y PaginationProps.

code:
  import { Pagination as MantinePagination } from "@mantine/core"

  interface PaginationProps {
    total: number
    value?: number
    defaultValue?: number
    onChange?: (page: number) => void
    siblings?: number
    boundaries?: number
    withEdges?: boolean
    className?: string
  }

  function Pagination({
    total,
    value,
    defaultValue,
    onChange,
    siblings,
    boundaries,
    withEdges = false,
    className,
    ...props
  }: PaginationProps) {
    return (
      <MantinePagination
        data-slot="pagination"
        total={total}
        value={value}
        defaultValue={defaultValue}
        onChange={onChange}
        siblings={siblings}
        boundaries={boundaries}
        withEdges={withEdges}
        className={className}
        size="sm"
        {...props}
      />
    )
  }

  export { Pagination }
  export type { PaginationProps }


id: password_input_ts_ui
name: password_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: PasswordInput(props: PasswordInputProps): JSX.Element
tags: ["password","input","form","security","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/password_input.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onChange","onVisibilityChange"]
framework: react
variant: []
content_hash: 29503ada68e66494a5b095dc85c226b531d7e2d147a521c5a36e32c294bc3266
description:
  Input de contraseña con toggle de visibilidad y soporte para indicador de fortaleza. Wrapper sobre Mantine PasswordInput.

example:
  ```tsx
  import { PasswordInput } from '@fn_library'

  // Uso básico
  function LoginForm() {
    return (
      <PasswordInput
        label="Contraseña"
        placeholder="Tu contraseña"
      />
    )
  }

  // Con visibilidad controlada
  import { useState } from 'react'

  function ControlledPasswordInput() {
    const [visible, setVisible] = useState(false)
    const [value, setValue] = useState('')

    return (
      <PasswordInput
        label="Contraseña"
        placeholder="Ingresa tu contraseña"
        value={value}
        onChange={(e) => setValue(e.currentTarget.value)}
        visible={visible}
        onVisibilityChange={setVisible}
        error={value.length > 0 && value.length < 8 ? 'Mínimo 8 caracteres' : undefined}
      />
    )
  }
  ```

props:
  [{"name":"visible","type":"boolean","required":false,"description":"Controla externamente si la contraseña es visible"},{"name":"onVisibilityChange","type":"(visible: boolean) =\u003e void","required":false,"description":"Callback cuando el usuario cambia la visibilidad"},{"name":"visibilityToggleIcon","type":"React.FC\u003c{ reveal: boolean }\u003e","required":false,"description":"Icono personalizado para el botón de toggle"},{"name":"value","type":"string","required":false,"description":"Valor controlado del input"},{"name":"onChange","type":"(event: React.ChangeEvent\u003cHTMLInputElement\u003e) =\u003e void","required":false,"description":"Callback al cambiar el valor"},{"name":"label","type":"React.ReactNode","required":false,"description":"Etiqueta del campo"},{"name":"placeholder","type":"string","required":false,"description":"Texto placeholder cuando está vacío"},{"name":"error","type":"React.ReactNode","required":false,"description":"Mensaje de error a mostrar bajo el input"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  Wrapper delgado sobre `PasswordInput` de Mantine v9. El toggle de visibilidad es nativo de Mantine y se controla opcionalmente con `visible` + `onVisibilityChange`. Soporta todas las props de Mantine incluyendo `strengthMeter` para indicadores de fortaleza. Los iconos del toggle deben venir de `@tabler/icons-react`.

code:
  import { PasswordInput as MantinePasswordInput, type PasswordInputProps as MantinePasswordInputProps } from '@mantine/core'

  interface PasswordInputProps extends MantinePasswordInputProps {}

  function PasswordInput(props: PasswordInputProps) {
    return <MantinePasswordInput {...props} />
  }

  export { PasswordInput }
  export type { PasswordInputProps }

params_schema:
  {"params":[{"name":"props","desc":"Props de Mantine PasswordInput: visible, onVisibilityChange, visibilityToggleIcon, value, onChange, label, placeholder, error, disabled, size y cualquier prop HTML nativa de input"}],"output":"Componente PasswordInput que renderiza input enmascarado con botón de mostrar/ocultar"}


id: pie_chart_ts_ui
name: pie_chart
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: PieChart(props: PieChartProps): JSX.Element
tags: ["chart","pie","donut","visualization","mantine","component","ui","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/charts","@mantine/core"]
tests: []
file_path: frontend/functions/ui/pie_chart.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["pie","donut"]
content_hash: b4d9769687fc422e7957b81d89383552ffc3499cd52d404ecac185d1d318254f
params_schema: {"output":"Componente PieChart que renderiza gráfico de torta o dona con labels y tooltip"}
description:
  Gráfico de torta/dona @mantine/charts con colores automáticos, labels y tooltip. Usa DonutChart para dona, PieChart para torta.

example:
  ```tsx
  // Pie simple
  <PieChart
    data={[{ lang: 'Go', count: 42 }, { lang: 'Python', count: 28 }, { lang: 'Bash', count: 15 }]}
    nameKey="lang"
    valueKey="count"
  />

  // Dona sin labels
  <PieChart
    data={distributions}
    nameKey="domain"
    valueKey="functions"
    donut
    showLabels={false}
    valueFormatter={(v) => `${v} fns`}
  />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Array de datos. Los valores de valueKey se convierten a number automáticamente."},{"name":"nameKey","type":"string","required":true,"description":"Key del campo que contiene el nombre/etiqueta de cada segmento"},{"name":"valueKey","type":"string","required":true,"description":"Key del campo numérico que determina el tamaño de cada segmento"},{"name":"colors","type":"string[]","required":false,"description":"Paleta de colores hex. Default: 8 colores accesibles. Se repite cíclicamente."},{"name":"donut","type":"boolean","required":false,"description":"Modo dona. innerRadius pasa a 50 por defecto cuando donut=true."},{"name":"showLegend","type":"boolean","required":false,"description":"Mostrar leyenda. Default true."},{"name":"showLabels","type":"boolean","required":false,"description":"Mostrar labels nombre+% en cada segmento. Default true."},{"name":"height","type":"number | string","required":false,"description":"Altura del contenedor. Default 300."},{"name":"valueFormatter","type":"(value: number) =\u003e string","required":false,"description":"Formateador de valores para el tooltip. Default toLocaleString."}]

notes:
  Los valores de `valueKey` se convierten a `Number()` antes de pasarlos al chart. Cuando `donut=true` se usa `DonutChart` de Mantine, de lo contrario `PieChart`.

code:
  import { PieChart as MantinePieChart, DonutChart } from '@mantine/charts'
  import { Paper } from '@mantine/core'

  const DEFAULT_COLORS = [
    '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
    '#ec4899', '#06b6d4', '#f97316',
  ]

  interface PieChartProps {
    data: Record<string, unknown>[]
    nameKey: string
    valueKey: string
    colors?: string[]
    donut?: boolean
    showLegend?: boolean
    showLabels?: boolean
    height?: number
    valueFormatter?: (value: number) => string
  }

  function PieChartComponent({
    data,
    nameKey,
    valueKey,
    colors = DEFAULT_COLORS,
    donut = false,
    showLegend: _showLegend = true,
    showLabels = true,
    height = 300,
    valueFormatter = (v) => v.toLocaleString(),
  }: PieChartProps) {
    const chartData = data.map((row, i) => ({
      name: String(row[nameKey] ?? ''),
      value: Number(row[valueKey]) || 0,
      color: colors[i % colors.length] as string,
    }))

    const Chart = donut ? DonutChart : MantinePieChart

    return (
      <Paper p="md">
        <Chart
          h={height}
          data={chartData}
          withLabels={showLabels}
          withLabelsLine={showLabels}
          withTooltip
          tooltipDataSource="segment"
          valueFormatter={valueFormatter}
        />
        {/* Mantine PieChart/DonutChart does not have a built-in legend prop;
            legend is handled via withLabels. showLegend kept for API compat. */}
      </Paper>
    )
  }

  export const PieChart = PieChartComponent
  export type { PieChartProps }


id: pin_input_ts_ui
name: pin_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: PinInput(props: PinInputProps): JSX.Element
description: Input de código PIN/OTP con campos individuales y autocompletado. Wrapper sobre Mantine PinInput.
tags: ["pin","otp","code","input","form","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/pin_input.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange","onComplete"]
framework: react
variant: ["default"]
content_hash: 5820e3acc47a8166dd8995c9c0b2bd199606650c83339d6a11a034ea05b6f4c0
example:
  ```tsx
  import { PinInput } from '@fn_library'

  // PIN numérico de 4 dígitos
  <PinInput
    length={4}
    type="number"
    onComplete={(value) => console.log('PIN completo:', value)}
  />

  // OTP de 6 dígitos con autocompletado móvil
  <PinInput
    length={6}
    type="number"
    oneTimeCode
    onComplete={handleOtpSubmit}
  />

  // PIN enmascarado alfanumérico
  <PinInput
    length={5}
    type="alphanumeric"
    mask
    placeholder="*"
    value={pin}
    onChange={setPin}
  />
  ```

props:
  [{"name":"length","type":"number","required":false,"description":"Número de campos individuales del PIN (por defecto 4)"},{"name":"type","type":"'number' | 'alphanumeric'","required":false,"description":"Tipo de caracteres aceptados — solo números o también letras"},{"name":"mask","type":"boolean","required":false,"description":"Oculta los caracteres ingresados como contraseña"},{"name":"oneTimeCode","type":"boolean","required":false,"description":"Activa el atributo autocomplete=one-time-code para OTP en móvil"},{"name":"value","type":"string","required":false,"description":"Valor actual del PIN (controlled)"},{"name":"onChange","type":"(value: string) =\u003e void","required":false,"description":"Callback al cambiar cualquier campo"},{"name":"onComplete","type":"(value: string) =\u003e void","required":false,"description":"Callback cuando todos los campos están completos"},{"name":"placeholder","type":"string","required":false,"description":"Carácter placeholder en cada campo vacío"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita todos los campos"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño visual de los campos"}]

notes:
  - Wrapper directo sobre `PinInput` de `@mantine/core` v9. Todas las props de Mantine PinInput son válidas.
  - `oneTimeCode` genera `autocomplete="one-time-code"` para que el browser/SMS sugiera el código automáticamente.
  - Cada campo avanza al siguiente automáticamente al completarse.
  - Soporta navegación con teclado (flechas, backspace) entre campos.

code:
  import { PinInput as MantinePinInput, type PinInputProps as MantinePinInputProps } from '@mantine/core'

  interface PinInputProps extends MantinePinInputProps {}

  function PinInput(props: PinInputProps) {
    return <MantinePinInput {...props} />
  }

  export { PinInput }
  export type { PinInputProps }

params_schema:
  {"output":"Componente PinInput que renderiza campos individuales por dígito con soporte para autocompletado OTP, máscaras y teclado numérico o alfanumérico"}


id: popover_ts_ui
name: popover
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Popover(props: PopoverProps): JSX.Element
description: Contenido flotante posicionado accesible con animaciones. Mantine Popover.
tags: ["popover","component","ui","interactive","overlay","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/popover.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onOpenChange"]
framework: react
variant: []
content_hash: 1f477c963d7e33f71463adfa24c1120fce8cfe4cc9dabcbad3a5443f707f5285
example:
  ```tsx
  <Popover>
    <PopoverTrigger asChild>
      <Button variant="outline">Abrir</Button>
    </PopoverTrigger>
    <PopoverContent>
      <PopoverHeader>
        <PopoverTitle>Configuracion</PopoverTitle>
        <PopoverDescription>Ajusta tus preferencias.</PopoverDescription>
      </PopoverHeader>
      <div className="mt-4">
        {/* contenido */}
      </div>
    </PopoverContent>
  </Popover>
  ```

props:
  [{"name":"open","type":"boolean","required":false,"description":"Estado controlado de apertura"},{"name":"defaultOpen","type":"boolean","required":false,"description":"Estado inicial de apertura (no controlado)"},{"name":"onOpenChange","type":"(open: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado de apertura"},{"name":"sideOffset","type":"number","required":false,"description":"Distancia en px entre trigger y popover (default: 4)"}]

notes:
  Compuesto de: Popover (root), PopoverTrigger, PopoverContent, PopoverClose, PopoverHeader, PopoverTitle, PopoverDescription. El posicionamiento automatico lo maneja Mantine Popover. PopoverPortal es un no-op mantenido por compatibilidad.

code:
  import * as React from 'react'
  import { Popover as MantinePopover, Box, Text } from '@mantine/core'

  interface PopoverProps {
    open?: boolean
    defaultOpen?: boolean
    onOpenChange?: (open: boolean) => void
    children: React.ReactNode
  }

  function Popover({ open, defaultOpen, onOpenChange, children }: PopoverProps) {
    return (
      <MantinePopover
        opened={open}
        defaultOpened={defaultOpen}
        onChange={onOpenChange}
        withArrow
        shadow="md"
        radius="md"
      >
        {children}
      </MantinePopover>
    )
  }

  function PopoverTrigger({ children }: React.ComponentProps<'div'>) {
    return (
      <MantinePopover.Target>
        {React.isValidElement(children) ? (
          children
        ) : (
          <button type="button" data-slot="popover-trigger">{children}</button>
        )}
      </MantinePopover.Target>
    )
  }

  function PopoverPortal({ children }: { children: React.ReactNode }) {
    return <>{children}</>
  }

  function PopoverContent({ className, children, sideOffset, ...props }: React.ComponentProps<'div'> & { sideOffset?: number }) {
    return (
      <MantinePopover.Dropdown
        data-slot="popover-content"
        className={className}
        {...props}
      >
        {children}
      </MantinePopover.Dropdown>
    )
  }

  function PopoverClose({ children, ...props }: React.ComponentProps<'button'>) {
    return (
      <button type="button" data-slot="popover-close" {...props}>
        {children}
      </button>
    )
  }

  function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
    return <Box data-slot="popover-header" mb="xs" className={className} {...props} />
  }

  function PopoverTitle({ className, ...props }: React.ComponentProps<'h4'>) {
    return (
      <Text
        component="h4"
        data-slot="popover-title"
        fw={600}
        size="sm"
        className={className}
        {...props}
      />
    )
  }

  function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
    return (
      <Text
        component="p"
        data-slot="popover-description"
        size="sm"
        c="dimmed"
        className={className}
        {...props}
      />
    )
  }

  export { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverPortal, PopoverTitle, PopoverTrigger }

params_schema:
  {"output":"Componente Popover que renderiza contenido flotante accesible posicionado automáticamente via Mantine Popover"}


id: progress_bar_ts_ui
name: progress_bar
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ProgressBar(props: ProgressBarProps): JSX.Element
tags: ["progress","loading","component","ui","feedback","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/progress_bar.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["primary","success","warning","destructive"]
content_hash: 16ff4611af8e0c99ef105aa44c187025fcdd1e7b000ebc1e296c02010455ffcf
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/progress/progress-bar.tsx
description:
  Barra de progreso con variantes de color y tamaño, buffer, animación, modo indeterminado y display de valor. Mantine Progress.

example:
  ```tsx
  <ProgressBar value={75} color="success" showValue />
  <ProgressBar value={0} indeterminate />
  <ProgressBar value={50} buffer={80} animated />
  <ProgressBar value={30} size="lg" color="warning" />
  ```

props:
  [{"name":"value","type":"number","required":true,"description":"Valor actual de progreso"},{"name":"max","type":"number","required":false,"description":"Valor máximo (default 100)"},{"name":"buffer","type":"number","required":false,"description":"Valor de buffer secundario (opcional)"},{"name":"showValue","type":"boolean","required":false,"description":"Mostrar porcentaje como texto superpuesto"},{"name":"animated","type":"boolean","required":false,"description":"Activar animación de rayas (stripes)"},{"name":"indeterminate","type":"boolean","required":false,"description":"Modo indeterminado sin valor conocido"},{"name":"size","type":"'sm' | 'md' | 'lg'","required":false,"description":"Altura de la barra (default md)"},{"name":"color","type":"'primary' | 'success' | 'warning' | 'destructive'","required":false,"description":"Color semántico (default primary)"},{"name":"label","type":"string","required":false,"description":"aria-label para accesibilidad"}]

notes:
  El porcentaje se clampea a [0, 100] internamente. El buffer se renderiza como capa semitransparente debajo del fill usando Progress.Root apilados. Los colores se mapean de semanticos (primary/success/warning/destructive) a colores Mantine. La animacion striped usa el prop animated nativo de Mantine.

code:
  import { Progress, Box } from "@mantine/core"

  export interface ProgressBarProps {
    value: number
    max?: number
    buffer?: number
    showValue?: boolean
    animated?: boolean
    indeterminate?: boolean
    label?: string
    size?: "sm" | "md" | "lg"
    color?: "primary" | "success" | "warning" | "destructive"
    className?: string
  }

  const colorMap: Record<NonNullable<ProgressBarProps["color"]>, string> = {
    primary: "blue",
    success: "green",
    warning: "yellow",
    destructive: "red",
  }

  const sizeMap: Record<NonNullable<ProgressBarProps["size"]>, number> = {
    sm: 4,
    md: 8,
    lg: 12,
  }

  export function ProgressBar({
    value,
    max = 100,
    buffer,
    showValue = false,
    animated = false,
    indeterminate = false,
    size = "md",
    color = "primary",
    label,
    className,
  }: ProgressBarProps) {
    const percentage = Math.min(100, Math.max(0, (value / max) * 100))
    const bufferPercentage = buffer ? Math.min(100, Math.max(0, (buffer / max) * 100)) : undefined
    const mantineColor = colorMap[color]
    const mantineSize = sizeMap[size]

    if (bufferPercentage !== undefined) {
      return (
        <Box data-slot="progress-bar" pos="relative" className={className}>
          <Progress.Root size={mantineSize} aria-label={label}>
            <Progress.Section value={bufferPercentage} color={mantineColor} style={{ opacity: 0.3 }} />
          </Progress.Root>
          <Box pos="absolute" top={0} left={0} right={0}>
            <Progress.Root size={mantineSize} bg="transparent" aria-label={label}>
              <Progress.Section
                value={indeterminate ? 100 : percentage}
                color={mantineColor}
                animated={animated || indeterminate}
              >
                {showValue && !indeterminate && (
                  <Progress.Label>{Math.round(percentage)}%</Progress.Label>
                )}
              </Progress.Section>
            </Progress.Root>
          </Box>
        </Box>
      )
    }

    return (
      <Progress.Root
        data-slot="progress-bar"
        size={mantineSize}
        aria-label={label}
        className={className}
      >
        <Progress.Section
          value={indeterminate ? 100 : percentage}
          color={mantineColor}
          animated={animated || indeterminate}
        >
          {showValue && !indeterminate && (
            <Progress.Label>{Math.round(percentage)}%</Progress.Label>
          )}
        </Progress.Section>
      </Progress.Root>
    )
  }

params_schema:
  {"output":"Componente ProgressBar que renderiza barra de progreso con animaciones, buffer y modo indeterminado"}


id: radio_group_ts_ui
name: radio_group
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: RadioGroup(props: RadioGroupProps): JSX.Element
description: Grupo de opciones exclusivas accesible. Mantine Radio.Group + Radio.
tags: ["radio","radio-group","component","ui","interactive","form","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/radio_group.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onValueChange"]
framework: react
variant: []
content_hash: 5d8ee9ac58e2a1c316a4e8926eb0b5cf672b8084b42cef0fe973646a8a63ff80
params_schema: {"output":"Componente RadioGroup que renderiza grupo de opciones exclusivas accesible"}
example:
  ```tsx
  <RadioGroup defaultValue="option-a">
    <RadioGroupItem value="option-a" label="Opcion A" />
    <RadioGroupItem value="option-b" label="Opcion B" />
    <RadioGroupItem value="option-c" label="Opcion C" disabled />
  </RadioGroup>
  ```

props:
  [{"name":"value","type":"string","required":false,"description":"Valor seleccionado (controlado)"},{"name":"defaultValue","type":"string","required":false,"description":"Valor inicial (no controlado)"},{"name":"onValueChange","type":"(value: string) =\u003e void","required":false,"description":"Callback al cambiar seleccion"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita todo el grupo"},{"name":"orientation","type":"'horizontal' | 'vertical'","required":false,"description":"Orientacion del grupo"}]

notes:
  RadioGroup es el contenedor (Mantine Radio.Group). RadioGroupItem es cada opcion individual (Mantine Radio). Soporta orientacion horizontal y vertical.

code:
  import * as React from "react"
  import { Radio } from "@mantine/core"

  interface RadioGroupProps {
    value?: string
    defaultValue?: string
    onValueChange?: (value: string) => void
    disabled?: boolean
    orientation?: "horizontal" | "vertical"
    className?: string
    children?: React.ReactNode
  }

  function RadioGroup({ className, value, defaultValue, onValueChange, orientation, children, ...props }: RadioGroupProps) {
    return (
      <Radio.Group
        data-slot="radio-group"
        value={value}
        defaultValue={defaultValue}
        onChange={(val) => onValueChange?.(val)}
        className={className}
        {...props}
      >
        <div style={{ display: 'flex', flexDirection: orientation === 'horizontal' ? 'row' : 'column', gap: 'var(--mantine-spacing-sm)' }}>
          {children}
        </div>
      </Radio.Group>
    )
  }

  interface RadioGroupItemProps {
    value: string
    label?: string
    disabled?: boolean
    className?: string
    labelClassName?: string
    id?: string
  }

  function RadioGroupItem({ className, label, id, disabled, value, ...props }: RadioGroupItemProps) {
    return (
      <Radio
        id={id}
        data-slot="radio-group-item"
        value={value}
        label={label}
        disabled={disabled}
        className={className}
        size="sm"
        {...props}
      />
    )
  }

  export { RadioGroup, RadioGroupItem }
  export type { RadioGroupItemProps }


id: rating_ts_ui
name: rating
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Rating(props: RatingProps): JSX.Element
description: Selector de calificación con estrellas, fracciones y símbolos custom. Wrapper sobre Mantine Rating.
tags: ["rating","stars","feedback","input","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/rating.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["default"]
content_hash: 8627d1b78c69dee7cc9a42a4558b8fbef3d3811510503b0b2461c2ac4e7067d3
example:
  ```tsx
  import { Rating } from '@fn_library'

  // Rating básico de 5 estrellas
  <Rating value={rating} onChange={setRating} />

  // Con medias estrellas
  <Rating
    fractions={2}
    value={3.5}
    onChange={setRating}
  />

  // Solo lectura para mostrar calificación
  <Rating value={4.2} fractions={10} readOnly />

  // Con más estrellas y color custom
  <Rating
    count={10}
    value={score}
    onChange={setScore}
    color="yellow"
    size="lg"
  />
  ```

props:
  [{"name":"count","type":"number","required":false,"description":"Número de estrellas/símbolos a mostrar (por defecto 5)"},{"name":"fractions","type":"number","required":false,"description":"Divisiones por estrella — 2 para mitades, 4 para cuartos"},{"name":"value","type":"number","required":false,"description":"Calificación actual (controlled)"},{"name":"onChange","type":"(value: number) =\u003e void","required":false,"description":"Callback al cambiar la calificación"},{"name":"readOnly","type":"boolean","required":false,"description":"Modo solo lectura — muestra la calificación sin permitir cambios"},{"name":"highlightSelectedOnly","type":"boolean","required":false,"description":"Solo resalta el símbolo seleccionado, no los anteriores"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño de los símbolos"},{"name":"color","type":"string","required":false,"description":"Color de los símbolos activos (color de Mantine o CSS)"}]

notes:
  - Wrapper directo sobre `Rating` de `@mantine/core` v9. Todas las props de Mantine Rating son válidas.
  - `fractions` permite selección granular: 2 = mitades (0.5, 1, 1.5...), 4 = cuartos (0.25, 0.5...).
  - En modo `readOnly` no dispara `onChange` y el cursor es el por defecto.
  - Accesible via teclado con soporte para lectores de pantalla.

code:
  import { Rating as MantineRating, type RatingProps as MantineRatingProps } from '@mantine/core'

  interface RatingProps extends MantineRatingProps {}

  function Rating(props: RatingProps) {
    return <MantineRating {...props} />
  }

  export { Rating }
  export type { RatingProps }

params_schema:
  {"output":"Componente Rating que renderiza un selector de estrellas interactivo con soporte para fracciones, modo solo lectura y símbolos personalizados"}


id: ring_progress_ts_ui
name: ring_progress
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnRingProgress(props: FnRingProgressProps): JSX.Element
description: Anillo de progreso con secciones coloreadas y label central. Wrapper sobre Mantine RingProgress.
tags: ["mantine","progress","ring","chart","metrics","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/ring_progress.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: fc814716118e9c0a7a98c1cfb060b98f0ee032e19a0dcb530a80b124ff334788
params_schema: {"output":"Anillo de progreso SVG con secciones coloreadas y slot central para label"}
example:
  ```tsx
  import { FnRingProgress } from '@fn_library'
  import { Text } from '@mantine/core'

  <FnRingProgress
    sections={[
      { value: 40, color: 'cyan' },
      { value: 25, color: 'orange' },
      { value: 15, color: 'grape' },
    ]}
    label={<Text ta="center" fw={700}>80%</Text>}
  />
  ```

props:
  [{"name":"sections","type":"RingProgressSection[]","required":true,"description":"Secciones del anillo con valor porcentual y color"},{"name":"size","type":"number","required":false,"description":"Tamano del anillo en px, default 120"},{"name":"thickness","type":"number","required":false,"description":"Grosor del trazo en px, default 12"},{"name":"label","type":"ReactNode","required":false,"description":"Contenido central del anillo (texto, icono, etc)"},{"name":"rootColor","type":"string","required":false,"description":"Color del fondo del anillo (parte sin llenar)"}]

notes:
  Wrapper sobre Mantine `RingProgress`. Las secciones son aditivas -- la suma de `value` no deberia exceder 100. El `label` se renderiza centrado dentro del anillo.

code:
  import * as React from 'react'
  import { RingProgress } from '@mantine/core'
  import type { RingProgressSection } from '@mantine/core'

  interface FnRingProgressProps {
    sections: RingProgressSection[]
    size?: number
    thickness?: number
    label?: React.ReactNode
    rootColor?: string
  }

  function FnRingProgress({
    sections,
    size = 120,
    thickness = 12,
    label,
    rootColor,
  }: FnRingProgressProps) {
    return (
      <RingProgress
        sections={sections}
        size={size}
        thickness={thickness}
        label={label}
        rootColor={rootColor}
      />
    )
  }

  export { FnRingProgress }
  export type { FnRingProgressProps }


id: search_bar_ts_ui
name: search_bar
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: SearchBar(props: SearchBarProps): JSX.Element
description: Search input with debounce, search icon, and clear button
tags: ["component","ui","search","input","debounce"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/search_bar.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: []
content_hash: b8143f0bf1b019a1c3e0d03edbd23d3fefed5a750c348dcbeb74c4f05db58b02
example:
  ```tsx
  import { SearchBar } from '@fn_library'

  function MyPage() {
    return (
      <SearchBar
        onSearch={(query) => console.log('search:', query)}
        placeholder="Search entities..."
        debounceMs={300}
      />
    )
  }
  ```

props:
  [{"name":"onSearch","type":"(query: string) =\u003e void","required":true,"description":"Called with the debounced search query"},{"name":"placeholder","type":"string","required":false,"description":"Placeholder text (default: Search...)"},{"name":"debounceMs","type":"number","required":false,"description":"Debounce delay in ms (default: 300)"},{"name":"className","type":"string","required":false,"description":"Additional CSS classes"}]

notes:
  - Debounce usa ref para evitar re-renders innecesarios del callback
  - El icono de clear solo aparece cuando hay texto
  - Usa CSS variables del tema para colores (border, input, foreground, muted-foreground)

code:
  import * as React from 'react'
  import { TextInput, CloseButton } from '@mantine/core'
  import { IconSearch } from '@tabler/icons-react'

  interface SearchBarProps {
    onSearch: (query: string) => void
    placeholder?: string
    debounceMs?: number
    className?: string
  }

  function SearchBar({
    onSearch,
    placeholder = 'Search...',
    debounceMs = 300,
    className,
  }: SearchBarProps) {
    const [query, setQuery] = React.useState('')
    const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
    const onSearchRef = React.useRef(onSearch)
    onSearchRef.current = onSearch

    React.useEffect(() => {
      if (timerRef.current) clearTimeout(timerRef.current)
      timerRef.current = setTimeout(() => {
        onSearchRef.current(query)
      }, debounceMs)
      return () => {
        if (timerRef.current) clearTimeout(timerRef.current)
      }
    }, [query, debounceMs])

    return (
      <TextInput
        value={query}
        onChange={(e) => setQuery(e.currentTarget.value)}
        placeholder={placeholder}
        leftSection={<IconSearch size={14} />}
        rightSection={query ? (
          <CloseButton size="sm" onClick={() => setQuery('')} aria-label="Clear search" />
        ) : undefined}
        className={className}
        size="sm"
      />
    )
  }

  export { SearchBar }
  export type { SearchBarProps }

params_schema:
  {"params":[{"name":"onSearch","desc":"Callback que se ejecuta con la query debounceada"},{"name":"placeholder","desc":"Placeholder del input (por defecto 'Search...')"},{"name":"debounceMs","desc":"Delay en milisegundos para el debounce (por defecto 300)"}],"output":"Componente SearchBar que renderiza input de búsqueda con icono y botón de limpiar"}


id: segmented_control_ts_ui
name: segmented_control
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnSegmentedControl(props: FnSegmentedControlProps): JSX.Element
description: Control segmentado para seleccion unica entre opciones. Wrapper sobre Mantine SegmentedControl.
tags: ["mantine","segmented","toggle","tabs","selection","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/segmented_control.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: 7bc2a95ed6b3eeada3a3e2c01b2bd02633c1ff7896fb84c603ace48110e187fa
params_schema: {"output":"Control segmentado con animacion de slide entre opciones"}
example:
  ```tsx
  import { FnSegmentedControl } from '@fn_library'

  <FnSegmentedControl
    value={view}
    onChange={setView}
    data={[
      { value: 'table', label: 'Tabla' },
      { value: 'chart', label: 'Grafico' },
      { value: 'map', label: 'Mapa' },
    ]}
  />
  ```

props:
  [{"name":"data","type":"{ value: string; label: string }[]","required":true,"description":"Opciones del control segmentado"},{"name":"value","type":"string","required":false,"description":"Valor seleccionado actualmente"},{"name":"onChange","type":"(value: string) =\u003e void","required":false,"description":"Callback cuando cambia la seleccion"},{"name":"fullWidth","type":"boolean","required":false,"description":"Ocupa todo el ancho disponible, default false"},{"name":"size","type":"MantineSize","required":false,"description":"Tamano del control, default sm"},{"name":"color","type":"MantineColor","required":false,"description":"Color del segmento activo"}]

notes:
  Wrapper sobre Mantine `SegmentedControl`. Alternativa a tabs para seleccion compacta entre pocas opciones. La animacion de slide es nativa de Mantine.

code:
  import { SegmentedControl } from '@mantine/core'
  import type { MantineSize, MantineColor } from '@mantine/core'

  interface SegmentedItem {
    value: string
    label: string
  }

  interface FnSegmentedControlProps {
    data: SegmentedItem[]
    value?: string
    onChange?: (value: string) => void
    fullWidth?: boolean
    size?: MantineSize
    color?: MantineColor
  }

  function FnSegmentedControl({
    data,
    value,
    onChange,
    fullWidth = false,
    size = 'sm',
    color,
  }: FnSegmentedControlProps) {
    return (
      <SegmentedControl
        data={data}
        value={value}
        onChange={onChange}
        fullWidth={fullWidth}
        size={size}
        color={color}
      />
    )
  }

  export { FnSegmentedControl }
  export type { FnSegmentedControlProps, SegmentedItem }


id: select_ts_ui
name: select
kind: component
lang: ts
domain: ui
version: 2.0.0
purity: impure
signature: Select(props: SelectProps): JSX.Element
tags: ["select","form","dropdown","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/select.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:21:29Z
emits: ["onChange"]
framework: react
variant: ["default"]
content_hash: 27015faca6a02f9672639dff69bb494084eb561bb16d866b4fb9797270fba0de
description:
  Select dropdown con búsqueda, grupos y accesibilidad. Wrapper sobre Mantine Select con API declarativa via prop data.

example:
  ```tsx
  import { Select } from '@fn_library'

  // Opciones simples (strings)
  <Select
    label="Tu librería favorita"
    placeholder="Elige una"
    data={['React', 'Angular', 'Vue', 'Svelte']}
  />

  // Opciones con value/label
  <Select
    value={selected}
    onChange={setSelected}
    data={[
      { value: 'react', label: 'React' },
      { value: 'vue', label: 'Vue' },
    ]}
  />

  // Con grupos
  <Select
    searchable
    data={[
      { group: 'Frontend', items: [{ value: 'react', label: 'React' }] },
      { group: 'Backend', items: [{ value: 'node', label: 'Node.js' }] },
    ]}
  />
  ```

props:
  [{"name":"data","type":"string[] | { value: string; label: string; disabled?: boolean }[] | { group: string; items: ... }[]","required":true,"description":"Opciones del select — strings, objetos {value,label}, o grupos"},{"name":"value","type":"string | null","required":false,"description":"Valor seleccionado (controlled)"},{"name":"onChange","type":"(value: string | null) =\u003e void","required":false,"description":"Callback al cambiar selección"},{"name":"defaultValue","type":"string | null","required":false,"description":"Valor inicial (uncontrolled)"},{"name":"placeholder","type":"string","required":false,"description":"Texto cuando no hay selección"},{"name":"label","type":"string","required":false,"description":"Label del campo"},{"name":"searchable","type":"boolean","required":false,"description":"Permite buscar entre opciones"},{"name":"clearable","type":"boolean","required":false,"description":"Permite limpiar la selección"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilitar el select"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  - Wrapper directo sobre `Select` de `@mantine/core` v9. Todas las props de Mantine Select son válidas.
  - Soporta `searchable` para filtrar opciones, `clearable` para limpiar, `allowDeselect` para deseleccionar.
  - Data acepta: `string[]`, `{ value, label }[]`, o `{ group, items }[]`.
  - Reemplaza al antiguo `select.tsx` basado en NativeSelect con sub-componentes stub.

code:
  import { Select as MantineSelect, type SelectProps as MantineSelectProps } from '@mantine/core'

  interface SelectProps extends MantineSelectProps {}

  function Select(props: SelectProps) {
    return <MantineSelect {...props} />
  }

  export { Select }
  export type { SelectProps }

params_schema:
  {"output":"Componente Select que renderiza dropdown searchable con soporte para opciones planas, agrupadas y custom render"}


id: settings_page_ts_ui
name: settings_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: settingsPage(props: SettingsPageProps): ReactElement
tags: ["settings","page","form","sections","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/settings_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 1b457c95ad2061c67c2702998df0de63a91a2151e0acdd41f05e152f057b8603
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera una página de configuración con navegación lateral, secciones y campos de formulario (text, number, toggle, select, textarea).

example:
  ```tsx
  settingsPage({
    title: 'Project Settings',
    sections: [
      {
        id: 'general',
        title: 'General',
        description: 'Basic project configuration',
        fields: [
          { key: 'name', label: 'Project Name', type: 'text', value: 'My Project' },
          { key: 'visibility', label: 'Public', description: 'Make project visible to everyone', type: 'toggle', value: true },
          { key: 'language', label: 'Language', type: 'select', options: [{ label: 'English', value: 'en' }, { label: 'Spanish', value: 'es' }] },
        ],
      },
      {
        id: 'notifications',
        title: 'Notifications',
        fields: [
          { key: 'email', label: 'Email notifications', type: 'toggle', value: false },
          { key: 'webhook', label: 'Webhook URL', type: 'text', placeholder: 'https://...' },
        ],
      },
    ],
    onSave: handleSave,
  })
  ```

notes:
  Layout de settings estándar con sidebar de navegación (oculta en mobile). Secciones con anclas para scroll. Soporta 5 tipos de campo.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, Paper, TextInput, Textarea, Switch, NativeSelect, Button, Box, Anchor } from '@mantine/core'

  interface SettingField {
    key: string
    label: string
    description?: string
    type: 'text' | 'number' | 'toggle' | 'select' | 'textarea'
    value?: unknown
    options?: Array<{ label: string; value: string }>
    placeholder?: string
  }

  interface SettingSection {
    id: string
    title: string
    description?: string
    fields: SettingField[]
  }

  interface SettingsPageProps {
    title?: string
    subtitle?: string
    sections: SettingSection[]
    onSave?: (values: Record<string, unknown>) => void
    className?: string
  }

  export function settingsPage({
    title = 'Settings',
    subtitle,
    sections,
    onSave,
  }: SettingsPageProps): React.ReactElement {
    return (
      <Stack gap="lg">
        {/* Header */}
        <Box pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Title order={2}>{title}</Title>
          {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
        </Box>

        {/* Content with sidebar nav */}
        <Group align="flex-start" gap="xl">
          <Box w={192} visibleFrom="md" style={{ flexShrink: 0 }}>
            <Stack gap={4}>
              {sections.map((section) => (
                <Anchor
                  key={section.id}
                  href={`#${section.id}`}
                  underline="never"
                  size="sm"
                  fw={500}
                  c="dimmed"
                  py={6}
                  px="sm"
                  style={{ display: 'block', borderRadius: 'var(--mantine-radius-sm)' }}
                >
                  {section.title}
                </Anchor>
              ))}
            </Stack>
          </Box>

          {/* Sections */}
          <Stack gap="xl" style={{ flex: 1 }}>
            {sections.map((section) => (
              <Stack key={section.id} id={section.id} gap="md">
                <Box>
                  <Text size="lg" fw={500}>{section.title}</Text>
                  {section.description && <Text size="sm" c="dimmed">{section.description}</Text>}
                </Box>
                <Paper withBorder p="md" radius="md">
                  <Stack gap="md">
                    {section.fields.map((field) => (
                      <Group key={field.key} justify="space-between" align="center" wrap="wrap" gap="md">
                        <Stack gap={2} style={{ flex: '1 1 auto' }}>
                          <Text size="sm" fw={500}>{field.label}</Text>
                          {field.description && <Text size="xs" c="dimmed">{field.description}</Text>}
                        </Stack>
                        <Box w={{ base: '100%', sm: 256 }}>
                          {field.type === 'toggle' ? (
                            <Switch defaultChecked={!!field.value} />
                          ) : field.type === 'select' ? (
                            <NativeSelect
                              data={field.options?.map(o => ({ label: o.label, value: o.value })) ?? []}
                              size="xs"
                            />
                          ) : field.type === 'textarea' ? (
                            <Textarea
                              rows={3}
                              placeholder={field.placeholder}
                              defaultValue={String(field.value ?? '')}
                              size="xs"
                            />
                          ) : (
                            <TextInput
                              type={field.type}
                              placeholder={field.placeholder}
                              defaultValue={String(field.value ?? '')}
                              size="xs"
                            />
                          )}
                        </Box>
                      </Group>
                    ))}
                  </Stack>
                </Paper>
              </Stack>
            ))}

            {onSave && (
              <Group justify="flex-end" pt="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
                <Button size="xs">Save changes</Button>
              </Group>
            )}
          </Stack>
        </Group>
      </Stack>
    )
  }

  export type { SettingsPageProps, SettingSection, SettingField }

params_schema:
  {"params":[{"name":"props","desc":"Configuración: título, secciones con campos (text, number, toggle, select, textarea) y callback onSave"}],"output":"Componente ReactElement que renderiza página de configuración con sidebar y secciones navegables"}


id: sheet_ts_ui
name: sheet
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Sheet(props: SheetProps): JSX.Element
description: Panel lateral deslizante (drawer) accesible con variantes de lado y animaciones. Mantine Drawer.
tags: ["sheet","drawer","panel","component","ui","interactive","overlay","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/sheet.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onOpenChange"]
framework: react
variant: ["top","bottom","left","right"]
content_hash: 74d563253638d54ef9be94e4040eafc3922d8251925fd655c2739a22531ffa56
example:
  ```tsx
  <Sheet>
    <SheetTrigger asChild>
      <Button variant="outline">Abrir panel</Button>
    </SheetTrigger>
    <SheetContent side="right">
      <SheetHeader>
        <SheetTitle>Editar perfil</SheetTitle>
        <SheetDescription>Realiza cambios en tu perfil.</SheetDescription>
      </SheetHeader>
      <div className="py-4">
        {/* contenido del panel */}
      </div>
      <SheetFooter>
        <SheetClose asChild>
          <Button variant="outline">Cancelar</Button>
        </SheetClose>
        <Button>Guardar</Button>
      </SheetFooter>
    </SheetContent>
  </Sheet>
  ```

props:
  [{"name":"side","type":"'top' | 'bottom' | 'left' | 'right'","required":false,"description":"Lado desde el que aparece el panel (default: right)"},{"name":"showCloseButton","type":"boolean","required":false,"description":"Muestra el boton de cierre (default: true)"},{"name":"open","type":"boolean","required":false,"description":"Estado controlado de apertura"},{"name":"onOpenChange","type":"(open: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"}]

notes:
  Reutiliza Mantine Drawer para el comportamiento modal y animaciones. SheetPortal y SheetOverlay son no-ops mantenidos por compatibilidad. Exports: Sheet, SheetTrigger, SheetContent, SheetClose, SheetPortal, SheetOverlay, SheetHeader, SheetFooter, SheetTitle, SheetDescription.

code:
  import * as React from 'react'
  import { Drawer, Box, Text, Group } from '@mantine/core'

  type SheetSide = 'top' | 'bottom' | 'left' | 'right'

  interface SheetProps {
    open?: boolean
    onOpenChange?: (open: boolean) => void
    children: React.ReactNode
  }

  interface SheetContentProps extends React.ComponentProps<'div'> {
    side?: SheetSide
    showCloseButton?: boolean
    children?: React.ReactNode
  }

  const SheetContext = React.createContext<{
    open: boolean
    setOpen: (open: boolean) => void
  }>({ open: false, setOpen: () => {} })

  function Sheet({ open: controlledOpen, onOpenChange, children }: SheetProps) {
    const [internalOpen, setInternalOpen] = React.useState(false)
    const open = controlledOpen ?? internalOpen
    const setOpen = React.useCallback(
      (v: boolean) => {
        onOpenChange?.(v)
        if (controlledOpen === undefined) setInternalOpen(v)
      },
      [controlledOpen, onOpenChange],
    )
    return (
      <SheetContext.Provider value={{ open, setOpen }}>
        {children}
      </SheetContext.Provider>
    )
  }

  function SheetTrigger({ children, ...props }: React.ComponentProps<'button'>) {
    const { setOpen } = React.useContext(SheetContext)
    return (
      <button type="button" data-slot="sheet-trigger" onClick={() => setOpen(true)} {...props}>
        {children}
      </button>
    )
  }

  function SheetClose({ children, ...props }: React.ComponentProps<'button'>) {
    const { setOpen } = React.useContext(SheetContext)
    return (
      <button type="button" data-slot="sheet-close" onClick={() => setOpen(false)} {...props}>
        {children}
      </button>
    )
  }

  function SheetPortal({ children }: { children: React.ReactNode }) {
    return <>{children}</>
  }

  function SheetOverlay() {
    return null
  }

  const sizeMap: Record<SheetSide, string> = {
    left: 'sm',
    right: 'sm',
    top: 'md',
    bottom: 'md',
  }

  function SheetContent({ side = 'right', showCloseButton = true, children, className, ...props }: SheetContentProps) {
    const { open, setOpen } = React.useContext(SheetContext)
    return (
      <Drawer
        opened={open}
        onClose={() => setOpen(false)}
        position={side}
        withCloseButton={showCloseButton}
        size={sizeMap[side]}
        padding="md"
        data-slot="sheet-content"
        className={className}
        {...props}
      >
        {children}
      </Drawer>
    )
  }

  function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
    return <Box data-slot="sheet-header" mb="xs" className={className} {...props} />
  }

  function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Group
        data-slot="sheet-footer"
        justify="flex-end"
        gap="sm"
        mt="auto"
        pt="sm"
        className={className}
        {...props}
      />
    )
  }

  function SheetTitle({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="sheet-title"
        fw={600}
        size="md"
        className={className}
        {...props}
      >
        {children}
      </Text>
    )
  }

  function SheetDescription({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="sheet-description"
        size="sm"
        c="dimmed"
        className={className}
        {...props}
      >
        {children}
      </Text>
    )
  }

  const sheetVariants = {} as const

  export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, sheetVariants }
  export type { SheetContentProps }

params_schema:
  {"output":"Componente Sheet que renderiza panel deslizante lateral accesible con animaciones via Mantine Drawer"}


id: simple_select_ts_ui
name: simple_select
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: SimpleSelect(props: SimpleSelectProps): JSX.Element
tags: ["select","dropdown","form","component","ui","simple"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/simple_select.tsx
created_at: 2026-04-06T00:12:20Z
updated_at: 2026-04-06T00:12:20Z
emits: []
framework: react
variant: ["default","sm"]
content_hash: 9599c3ff2261bdde59990b287fe2191849b325a8e73c5cd799ec69346f7b74db
description:
  Select simplificado que acepta un array plano o agrupado de opciones. Wrapper sobre Select con API declarativa.

example:
  ```tsx
  import { SimpleSelect } from '@fn_library'

  // Opciones planas
  const options = [
    { value: 'a', label: 'Opcion A' },
    { value: 'b', label: 'Opcion B' },
  ]

  <SimpleSelect value={selected} onValueChange={setSelected} options={options} />

  // Opciones agrupadas
  const grouped = [
    { group: 'Frutas', items: [{ value: 'apple', label: 'Manzana' }] },
    { group: 'Verduras', items: [{ value: 'carrot', label: 'Zanahoria' }] },
  ]

  <SimpleSelect value={selected} onValueChange={setSelected} options={grouped} />
  ```

props:
  [{"name":"value","type":"string","required":true,"description":"Valor seleccionado actualmente"},{"name":"onValueChange","type":"(value: string) =\u003e void","required":true,"description":"Callback cuando cambia la seleccion"},{"name":"options","type":"SimpleSelectOption[] | SimpleSelectGroup[]","required":true,"description":"Opciones planas o agrupadas"},{"name":"placeholder","type":"string","required":false,"description":"Texto cuando no hay seleccion"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el select"},{"name":"size","type":"'sm' | 'default'","required":false,"description":"Tamano del trigger"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  - Detecta automaticamente si las opciones son planas o agrupadas via type guard `isGrouped`.
  - Wrapper sobre `Select` del registry — toda la logica de Base-UI y accesibilidad viene del componente base.
  - Soporta items deshabilitados individualmente con `disabled: true` en cada opcion.

code:
  import { NativeSelect } from '@mantine/core'

  export interface SimpleSelectOption {
    value: string
    label: string
    disabled?: boolean
  }

  export interface SimpleSelectGroup {
    group: string
    items: SimpleSelectOption[]
  }

  export type SimpleSelectOptions = SimpleSelectOption[] | SimpleSelectGroup[]

  interface SimpleSelectProps {
    value: string
    onValueChange: (value: string) => void
    options: SimpleSelectOptions
    placeholder?: string
    disabled?: boolean
    size?: 'sm' | 'default'
    className?: string
  }

  function isGrouped(
    options: SimpleSelectOptions,
  ): options is SimpleSelectGroup[] {
    return options.length > 0 && options[0] != null && 'group' in options[0]
  }

  function toMantineData(options: SimpleSelectOptions, placeholder?: string) {
    const data: Array<{ value: string; label: string; disabled?: boolean } | { group: string; items: Array<{ value: string; label: string; disabled?: boolean }> }> = []

    if (placeholder) {
      data.push({ value: '', label: placeholder, disabled: true })
    }

    if (isGrouped(options)) {
      for (const g of options) {
        data.push({ group: g.group, items: g.items })
      }
    } else {
      for (const item of options) {
        data.push(item)
      }
    }

    return data
  }

  function SimpleSelect({
    value,
    onValueChange,
    options,
    placeholder = 'Select...',
    disabled = false,
    size = 'default',
    className,
  }: SimpleSelectProps) {
    return (
      <NativeSelect
        value={value}
        onChange={(e) => onValueChange(e.currentTarget.value)}
        data={toMantineData(options, !value ? placeholder : undefined)}
        disabled={disabled}
        size={size === 'sm' ? 'xs' : 'sm'}
        className={className}
        styles={{ input: { cursor: 'pointer' } }}
      />
    )
  }

  export { SimpleSelect }

params_schema:
  {"output":"Componente SimpleSelect que renderiza dropdown simplificado con soporte para opciones planas o agrupadas"}


id: skeleton_ts_ui
name: skeleton
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Skeleton(props: HTMLAttributes<HTMLDivElement>): JSX.Element
tags: ["skeleton","loading","placeholder","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/skeleton.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["base","text","card","avatar","button","table"]
content_hash: 398c66bd4f82c016175fa5ddf2c8c8ca172c66c50bb160bb312cfd8f9ccb3874
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/skeleton.tsx
description:
  Sistema de loading skeletons: base, text, card, avatar, button, table. Variantes preconfiguradas para estados de carga. Mantine Skeleton.

example:
  ```tsx
  <SkeletonCard />
  <SkeletonText lines={4} />
  <SkeletonTable rows={10} columns={5} />
  <SkeletonAvatar size="lg" />
  <SkeletonButton />
  ```

props:
  [{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"},{"name":"lines","type":"number","required":false,"description":"Número de líneas (SkeletonText, default 3)"},{"name":"rows","type":"number","required":false,"description":"Filas de tabla (SkeletonTable, default 5)"},{"name":"columns","type":"number","required":false,"description":"Columnas de tabla (SkeletonTable, default 4)"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del avatar (SkeletonAvatar, default md)"}]

notes:
  Exporta 6 variantes preconfiguradas. Todas componen sobre Mantine Skeleton con animacion nativa.
  La ultima linea de SkeletonText se acorta a 80% para simular texto real.
  SkeletonCard incluye imagen (180px) + dos lineas de texto. SkeletonAvatar usa circle para forma redonda.

code:
  import * as React from "react"
  import { Skeleton as MantineSkeleton, Group, Stack } from "@mantine/core"

  function Skeleton({ className, style, ...props }: React.HTMLAttributes<HTMLDivElement>) {
    return <MantineSkeleton data-slot="skeleton" className={className} style={style} {...props} />
  }

  function SkeletonText({ className, lines = 3, ...props }: React.HTMLAttributes<HTMLDivElement> & { lines?: number }) {
    return (
      <Stack gap="xs" className={className} data-slot="skeleton-text" {...props}>
        {Array.from({ length: lines }).map((_, i) => (
          <MantineSkeleton key={i} h={16} w={i === lines - 1 ? "80%" : "100%"} />
        ))}
      </Stack>
    )
  }

  function SkeletonCard({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
    return (
      <Stack gap="xs" className={className} data-slot="skeleton-card" {...props}>
        <MantineSkeleton h={180} w="100%" radius="md" />
        <MantineSkeleton h={16} w="75%" />
        <MantineSkeleton h={16} w="50%" />
      </Stack>
    )
  }

  const avatarSizeMap = { xs: 24, sm: 32, md: 40, lg: 48, xl: 64 }

  function SkeletonAvatar({ className, size = "md", ...props }: React.HTMLAttributes<HTMLDivElement> & { size?: "xs" | "sm" | "md" | "lg" | "xl" }) {
    const px = avatarSizeMap[size]
    return <MantineSkeleton data-slot="skeleton-avatar" circle h={px} w={px} className={className} {...props} />
  }

  function SkeletonButton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
    return <MantineSkeleton data-slot="skeleton-button" h={32} w={96} radius="md" className={className} {...props} />
  }

  function SkeletonTable({ className, rows = 5, columns = 4, ...props }: React.HTMLAttributes<HTMLDivElement> & { rows?: number; columns?: number }) {
    return (
      <Stack gap="xs" className={className} data-slot="skeleton-table" {...props}>
        <Group gap="sm">
          {Array.from({ length: columns }).map((_, i) => <MantineSkeleton key={i} h={16} style={{ flex: 1 }} />)}
        </Group>
        {Array.from({ length: rows }).map((_, rowIndex) => (
          <Group key={rowIndex} gap="sm">
            {Array.from({ length: columns }).map((_, colIndex) => <MantineSkeleton key={colIndex} h={16} style={{ flex: 1 }} />)}
          </Group>
        ))}
      </Stack>
    )
  }

  export { Skeleton, SkeletonAvatar, SkeletonButton, SkeletonCard, SkeletonTable, SkeletonText }

params_schema:
  {"output":"Componente Skeleton que renderiza placeholder de carga con 6 variantes preconfiguradas (text, card, avatar, button, table)"}


id: slider_ts_ui
name: slider
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Slider(props: SliderProps): JSX.Element | RangeSlider(props: RangeSliderProps): JSX.Element
tags: ["slider","range","input","numeric","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/slider.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange","onChangeEnd"]
framework: react
variant: ["default","range"]
content_hash: 9f30bcbfeb760650cd8f336dd02488671bf4f8a5953e6ac223b8c60aa7340297
description:
  Deslizador de valor numérico con marcas, labels y modo rango. Incluye RangeSlider. Wrapper sobre Mantine Slider.

example:
  ```tsx
  import { Slider, RangeSlider } from '@fn_library'

  // Slider básico
  <Slider
    value={volume}
    onChange={setVolume}
    min={0}
    max={100}
    step={5}
  />

  // Con marcas y label formateado
  <Slider
    value={price}
    onChange={setPrice}
    min={0}
    max={1000}
    step={50}
    label={(v) => `$${v}`}
    labelAlwaysOn
    marks={[
      { value: 0, label: '$0' },
      { value: 500, label: '$500' },
      { value: 1000, label: '$1000' },
    ]}
  />

  // RangeSlider para seleccionar intervalo
  <RangeSlider
    value={priceRange}
    onChange={setPriceRange}
    min={0}
    max={500}
    onChangeEnd={(range) => fetchProducts(range)}
  />
  ```

props:
  [{"name":"min","type":"number","required":false,"description":"Valor mínimo del rango (por defecto 0)"},{"name":"max","type":"number","required":false,"description":"Valor máximo del rango (por defecto 100)"},{"name":"step","type":"number","required":false,"description":"Incremento por paso al deslizar"},{"name":"marks","type":"{ value: number; label?: string }[]","required":false,"description":"Marcas en el track con etiquetas opcionales"},{"name":"value","type":"number","required":false,"description":"Valor actual del slider (controlled) — para RangeSlider es [number, number]"},{"name":"onChange","type":"(value: number) =\u003e void","required":false,"description":"Callback mientras se arrastra — para RangeSlider recibe [number, number]"},{"name":"onChangeEnd","type":"(value: number) =\u003e void","required":false,"description":"Callback al soltar el slider — para RangeSlider recibe [number, number]"},{"name":"label","type":"string | ((value: number) =\u003e string) | null","required":false,"description":"Tooltip sobre el thumb — función para formatear el valor"},{"name":"labelAlwaysOn","type":"boolean","required":false,"description":"Muestra el label siempre visible, no solo al hover"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita la interacción"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Grosor del track y tamaño del thumb"},{"name":"color","type":"string","required":false,"description":"Color del track activo y el thumb"}]

notes:
  - Wrapper directo sobre `Slider` y `RangeSlider` de `@mantine/core` v9. Todas las props de Mantine son válidas.
  - `RangeSlider` recibe y emite `[number, number]` en lugar de `number`.
  - Usar `onChangeEnd` para operaciones costosas (fetch, cálculos) y `onChange` solo para actualizar UI.
  - `label={null}` desactiva el tooltip completamente.

code:
  import { Slider as MantineSlider, RangeSlider as MantineRangeSlider } from '@mantine/core'
  import type { SliderProps as MantineSliderProps, RangeSliderProps as MantineRangeSliderProps } from '@mantine/core'

  interface SliderProps extends MantineSliderProps {}
  interface RangeSliderProps extends MantineRangeSliderProps {}

  function Slider(props: SliderProps) {
    return <MantineSlider {...props} />
  }

  function RangeSlider(props: RangeSliderProps) {
    return <MantineRangeSlider {...props} />
  }

  export { Slider, RangeSlider }
  export type { SliderProps, RangeSliderProps }

params_schema:
  {"output":"Componente Slider para selección de un valor numérico en un rango, o RangeSlider para selección de un intervalo [min, max]"}


id: sparkline_ts_ui
name: sparkline
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Sparkline(props: SparklineProps): JSX.Element
tags: ["sparkline","chart","inline","svg","component","ui","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/sparkline.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["line","area","bar"]
notes: SVG puro — sin dependencia de Recharts. Ideal para inline en tablas y cards.
content_hash: 918d4c618c1e02c554b7e50d98195c573c55d67bc1b08ca76f9cf9993f8cbf54
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/sparkline.tsx
description:
  Mini gráfico inline SVG puro (sin Recharts) con variantes line, area y bar. Para KPI cards y tablas.

example:
  ```tsx
  <Sparkline data={[10, 25, 15, 40, 30, 55]} variant="area" color="#22c55e" />
  <Sparkline data={[5, 3, 8, 1, 9]} variant="bar" width={60} height={20} />
  ```

props:
  [{"name":"data","type":"number[]","required":true,"description":"Array de valores numéricos"},{"name":"variant","type":"'line' | 'area' | 'bar'","required":false,"description":"Tipo de visualización"},{"name":"color","type":"string","required":false,"description":"Color del gráfico"},{"name":"colors","type":"string[]","required":false,"description":"Colores por barra para variant 'bar'. Cicla si es más corto que data."},{"name":"width","type":"number","required":false,"description":"Ancho en px (default 80)"},{"name":"height","type":"number","required":false,"description":"Alto en px (default 24)"}]

code:
  import * as React from 'react'
  import { Sparkline as MantineSparkline } from '@mantine/charts'

  type SparklineVariant = 'line' | 'area' | 'bar'

  interface SparklineProps {
    data: number[]
    variant?: SparklineVariant
    color?: string
    colors?: string[]
    width?: number
    height?: number
    strokeWidth?: number
    showLastPoint?: boolean
    className?: string
  }

  const Sparkline = React.forwardRef<HTMLDivElement, SparklineProps>(
    ({ data, variant = 'line', color, colors, width = 80, height = 24, strokeWidth = 1.5, className, ...props }, ref) => {
      if (data.length === 0) {
        return <div ref={ref} style={{ width, height }} className={className} />
      }

      // Bar variant: use custom SVG (Mantine Sparkline only supports area)
      if (variant === 'bar') {
        const min = Math.min(...data, 0)
        const max = Math.max(...data)
        const range = max - min || 1
        const p = 2
        const eh = height - p * 2
        const bw = (width - p * 2) / data.length - 1
        const barColor = color ?? 'currentColor'
        return (
          <div ref={ref} className={className} {...props}>
            <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
              {data.map((value, index) => {
                const bh = ((value - min) / range) * eh
                const x = p + index * ((width - p * 2) / data.length) + 0.5
                const y = p + eh - bh
                const fill = colors ? colors[index % colors.length] : barColor
                return <rect key={index} x={x} y={y} width={Math.max(bw, 1)} height={Math.max(bh, 1)} fill={fill} rx={1} opacity={0.85} />
              })}
            </svg>
          </div>
        )
      }

      // Line/area: use Mantine Sparkline
      return (
        <div ref={ref} className={className} {...props}>
          <MantineSparkline
            data={data}
            color={color ?? 'blue'}
            w={width}
            h={height}
            strokeWidth={strokeWidth}
            curveType="natural"
            withGradient={variant === 'area'}
            fillOpacity={variant === 'area' ? 0.3 : 0}
          />
        </div>
      )
    }
  )
  Sparkline.displayName = 'Sparkline'

  export { Sparkline, type SparklineProps, type SparklineVariant }

params_schema:
  {"output":"Componente Sparkline que renderiza mini gráfico SVG inline con variantes line, area y bar"}


id: stepper_ts_ui
name: stepper
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnStepper(props: FnStepperProps): JSX.Element
description: Stepper de pasos con orientacion horizontal/vertical. Wrapper sobre Mantine Stepper.
tags: ["mantine","stepper","steps","wizard","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/stepper.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: 6dfcdae814a7f1b5039fbfbc311e724139034e19bca3bad4cb39877a16a3e0f5
params_schema: {"output":"Stepper con indicadores de paso, labels y descripciones opcionales"}
example:
  ```tsx
  import { FnStepper } from '@fn_library'

  <FnStepper
    active={1}
    onStepClick={setActive}
    steps={[
      { label: 'Configuracion' },
      { label: 'Datos', description: 'Importar CSV' },
      { label: 'Revision' },
    ]}
  />
  ```

props:
  [{"name":"steps","type":"{ label: string; description?: string; icon?: ReactNode }[]","required":true,"description":"Definicion de cada paso del stepper"},{"name":"active","type":"number","required":true,"description":"Indice del paso activo (0-based)"},{"name":"onStepClick","type":"(step: number) =\u003e void","required":false,"description":"Callback al hacer click en un paso"},{"name":"orientation","type":"'horizontal' | 'vertical'","required":false,"description":"Orientacion del stepper, default horizontal"},{"name":"allowNextStepsSelect","type":"boolean","required":false,"description":"Permite seleccionar pasos futuros, default false"}]

notes:
  Wrapper sobre Mantine `Stepper`. Recibe un array declarativo de pasos en vez de children manuales. Soporta iconos custom por paso.

code:
  import * as React from 'react'
  import { Stepper } from '@mantine/core'

  interface StepDef {
    label: string
    description?: string
    icon?: React.ReactNode
  }

  interface FnStepperProps {
    steps: StepDef[]
    active: number
    onStepClick?: (step: number) => void
    orientation?: 'horizontal' | 'vertical'
    allowNextStepsSelect?: boolean
  }

  function FnStepper({
    steps,
    active,
    onStepClick,
    orientation = 'horizontal',
    allowNextStepsSelect = false,
  }: FnStepperProps) {
    return (
      <Stepper
        active={active}
        onStepClick={onStepClick}
        orientation={orientation}
        allowNextStepsSelect={allowNextStepsSelect}
      >
        {steps.map((step, i) => (
          <Stepper.Step
            key={i}
            label={step.label}
            description={step.description}
            icon={step.icon}
          />
        ))}
      </Stepper>
    )
  }

  export { FnStepper }
  export type { FnStepperProps, StepDef }


id: switch_toggle_ts_ui
name: switch_toggle
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: SwitchToggle(props: SwitchToggleProps): JSX.Element
description: Toggle on/off accesible con label opcional a izquierda o derecha. Mantine Switch.
tags: ["switch","toggle","component","ui","interactive","form","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/switch_toggle.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onCheckedChange"]
framework: react
variant: []
content_hash: 404ac6c3d356c36b3d0f3e480f83c0f84254e102cef2b7375d1c3648e8b8f284
params_schema: {"output":"Componente SwitchToggle que renderiza switch on/off accesible con label opcional"}
example:
  ```tsx
  // Label a la derecha (default)
  <SwitchToggle label="Notificaciones" defaultChecked />

  // Label a la izquierda
  <SwitchToggle label="Modo oscuro" labelPosition="left" checked={dark} onCheckedChange={setDark} />

  // Solo switch sin label
  <SwitchToggle checked={enabled} onCheckedChange={setEnabled} />
  ```

props:
  [{"name":"label","type":"string","required":false,"description":"Texto de etiqueta visible junto al switch"},{"name":"labelPosition","type":"'left' | 'right'","required":false,"description":"Posicion del label respecto al switch (default: right)"},{"name":"checked","type":"boolean","required":false,"description":"Estado controlado del toggle"},{"name":"defaultChecked","type":"boolean","required":false,"description":"Estado inicial no controlado"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el toggle"},{"name":"onCheckedChange","type":"(checked: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"}]

notes:
  Usa Mantine Switch. Soporta labelPosition nativo de Mantine para posicionar el label a izquierda o derecha. El callback onCheckedChange se adapta desde el onChange nativo.

code:
  import { Switch } from "@mantine/core"

  interface SwitchToggleProps {
    label?: string
    labelPosition?: "left" | "right"
    className?: string
    checked?: boolean
    defaultChecked?: boolean
    disabled?: boolean
    onCheckedChange?: (checked: boolean) => void
    id?: string
  }

  function SwitchToggle({ className, label, labelPosition = "right", id, checked, defaultChecked, disabled, onCheckedChange, ...props }: SwitchToggleProps) {
    return (
      <Switch
        id={id}
        data-slot="switch"
        label={label}
        labelPosition={labelPosition}
        checked={checked}
        defaultChecked={defaultChecked}
        disabled={disabled}
        onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
        className={className}
        size="sm"
        {...props}
      />
    )
  }

  export { SwitchToggle }
  export type { SwitchToggleProps }


id: tabs_ts_ui
name: tabs
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Tabs(props: TabsRootProps): JSX.Element
description: Sistema de tabs con orientacion horizontal/vertical, variantes default y line. Mantine Tabs.
tags: ["tabs","navigation","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/tabs.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onValueChange"]
framework: react
variant: ["default","line"]
content_hash: 45cc0d17c6cb092f9fc4e479e85b16765e3fe490056f00235338871108533594
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/tabs.tsx
example:
  ```tsx
  <Tabs defaultValue="general">
    <TabsList>
      <TabsTrigger value="general">General</TabsTrigger>
      <TabsTrigger value="security">Security</TabsTrigger>
    </TabsList>
    <TabsContent value="general">...</TabsContent>
    <TabsContent value="security">...</TabsContent>
  </Tabs>
  ```

props:
  [{"name":"orientation","type":"'horizontal' | 'vertical'","required":false,"description":"Orientación de los tabs"},{"name":"variant","type":"'default' | 'line'","required":false,"description":"Estilo visual de la lista"}]

code:
  import * as React from "react"
  import { Tabs as MantineTabs } from "@mantine/core"

  interface TabsProps {
    defaultValue?: string | null
    value?: string | null
    onTabChange?: (value: string | null) => void
    orientation?: "horizontal" | "vertical"
    variant?: "default" | "line"
    className?: string
    children?: React.ReactNode
  }

  function Tabs({ className, orientation = "horizontal", variant = "default", defaultValue, value, onTabChange, children, ...props }: TabsProps) {
    return (
      <MantineTabs
        data-slot="tabs"
        defaultValue={defaultValue}
        value={value}
        onChange={onTabChange}
        orientation={orientation}
        variant={variant === "line" ? "outline" : "default"}
        className={className}
        {...props}
      >
        {children}
      </MantineTabs>
    )
  }

  function TabsList({ className, variant, children, ...props }: { className?: string; variant?: "default" | "line"; children?: React.ReactNode } & React.ComponentProps<typeof MantineTabs.List>) {
    return (
      <MantineTabs.List data-slot="tabs-list" className={className} {...props}>
        {children}
      </MantineTabs.List>
    )
  }

  function TabsTrigger({ className, value, children, disabled, ...props }: { className?: string; value: string; children?: React.ReactNode; disabled?: boolean }) {
    return (
      <MantineTabs.Tab data-slot="tabs-trigger" value={value} disabled={disabled} className={className} {...props}>
        {children}
      </MantineTabs.Tab>
    )
  }

  function TabsContent({ className, value, children, ...props }: { className?: string; value: string; children?: React.ReactNode }) {
    return (
      <MantineTabs.Panel data-slot="tabs-content" value={value} className={className} {...props}>
        {children}
      </MantineTabs.Panel>
    )
  }

  export { Tabs, TabsList, TabsTrigger, TabsContent }

params_schema:
  {"output":"Componente Tabs que renderiza sistema de navegación por tabs con orientación y variantes configurables"}


id: tags_input_ts_ui
name: tags_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: TagsInput(props: TagsInputProps): JSX.Element
tags: ["tags","input","form","pills","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/tags_input.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: []
content_hash: cd3ed5231ca84a999df7d62763d992f90132e198830e0a753e2c3d03a6f554b8
params_schema: {"output":"Componente TagsInput que renderiza input de tags con creación libre y sugerencias"}
description:
  Input de tags libre con sugerencias opcionales. Permite crear valores custom a diferencia de MultiSelect. Wrapper sobre Mantine TagsInput.

example:
  ```tsx
  import { TagsInput } from '@fn_library'

  // Tags libres sin sugerencias
  <TagsInput
    label="Tecnologías"
    placeholder="Añade una tecnología y presiona Enter"
    onChange={(tags) => console.log(tags)}
  />

  // Con sugerencias y límite de tags
  <TagsInput
    label="Categorías"
    placeholder="Selecciona o escribe"
    data={['Frontend', 'Backend', 'DevOps', 'QA', 'Diseño']}
    maxTags={3}
    clearable
    onChange={(tags) => setSelectedTags(tags)}
  />
  ```

props:
  [{"name":"data","type":"string[]","required":false,"description":"Lista de sugerencias para autocompletar"},{"name":"value","type":"string[]","required":false,"description":"Valor controlado: array de tags activos"},{"name":"onChange","type":"(value: string[]) =\u003e void","required":false,"description":"Callback cuando cambia la lista de tags"},{"name":"maxTags","type":"number","required":false,"description":"Número máximo de tags permitidos"},{"name":"allowDuplicates","type":"boolean","required":false,"description":"Permite tags duplicados. Por defecto false"},{"name":"splitChars","type":"string[]","required":false,"description":"Caracteres que dividen la entrada en tags. Por defecto [',']"},{"name":"clearable","type":"boolean","required":false,"description":"Muestra botón para limpiar todos los tags"},{"name":"placeholder","type":"string","required":false,"description":"Texto placeholder cuando no hay tags"},{"name":"label","type":"string","required":false,"description":"Etiqueta visible sobre el input"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  A diferencia de MultiSelect, TagsInput permite crear valores que no están en la lista `data`. El usuario escribe libremente y confirma con Enter o con los caracteres definidos en `splitChars`. Útil para campos de etiquetas, habilidades o keywords donde el conjunto no es cerrado.

code:
  import { TagsInput as MantineTagsInput, type TagsInputProps as MantineTagsInputProps } from '@mantine/core'

  interface TagsInputProps extends MantineTagsInputProps {}

  function TagsInput(props: TagsInputProps) {
    return <MantineTagsInput {...props} />
  }

  export { TagsInput }
  export type { TagsInputProps }


id: textarea_ts_ui
name: textarea
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Textarea(props: TextareaProps): JSX.Element
description: Input multilinea accesible con auto-resize opcional. Mantine Textarea con autosize.
tags: ["textarea","component","ui","interactive","form","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/textarea.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onChange","onFocus","onBlur"]
framework: react
variant: []
content_hash: 41f1d9b15cf6c8123d42e3a0d96e5ae856b9ce2fbeeb3d0824e51a113f47d4ff
params_schema: {"output":"Componente Textarea que renderiza input multilinea accesible con auto-resize opcional"}
example:
  ```tsx
  // Basico
  <Textarea placeholder="Escribe aqui..." rows={4} />

  // Con auto-resize
  <Textarea autoResize placeholder="Crece automaticamente..." />

  // Controlado
  <Textarea value={text} onChange={(e) => setText(e.target.value)} />

  // Con validacion
  <Textarea aria-invalid={!!error} />
  ```

props:
  [{"name":"autoResize","type":"boolean","required":false,"description":"Ajusta la altura automaticamente al contenido (default: false)"},{"name":"placeholder","type":"string","required":false,"description":"Texto placeholder"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el textarea"},{"name":"rows","type":"number","required":false,"description":"Numero de filas visibles iniciales"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Usa Mantine Textarea con autosize nativo (reemplaza el auto-resize manual anterior). forwardRef mantenido para compatibilidad con form libraries.

code:
  import * as React from 'react'
  import { Textarea as MantineTextarea } from '@mantine/core'

  interface TextareaProps extends Omit<React.ComponentProps<typeof MantineTextarea>, 'autosize'> {
    autoResize?: boolean
  }

  const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
    ({ className, autoResize = false, ...props }, ref) => {
      return (
        <MantineTextarea
          ref={ref}
          data-slot="textarea"
          autosize={autoResize}
          minRows={autoResize ? 2 : undefined}
          size="sm"
          className={className}
          {...props}
        />
      )
    }
  )
  Textarea.displayName = 'Textarea'

  export { Textarea }
  export type { TextareaProps }


id: timeline_ts_ui
name: timeline
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnTimeline(props: FnTimelineProps): JSX.Element
description: Timeline vertical con items, iconos y colores. Wrapper sobre Mantine Timeline.
tags: ["mantine","timeline","history","events","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/timeline.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: f2cd3540feb6ef76cba54f62e0b0bcde882eac4f2f28f66b5d15506b7ac09f07
params_schema: {"output":"Timeline vertical con bullets, titulos, timestamps y descripciones"}
example:
  ```tsx
  import { FnTimeline } from '@fn_library'

  <FnTimeline
    active={1}
    items={[
      { title: 'Creado', time: '2024-01-01', icon: <IconCheck size={12} /> },
      { title: 'En progreso', description: 'Pipeline ejecutando', color: 'blue' },
      { title: 'Completado' },
    ]}
  />
  ```

props:
  [{"name":"items","type":"{ title: string; description?: string; icon?: ReactNode; color?: string; time?: string }[]","required":true,"description":"Items de la timeline con titulo, descripcion, icono, color y timestamp"},{"name":"active","type":"number","required":false,"description":"Indice del item activo, default -1 (ninguno)"},{"name":"bulletSize","type":"number","required":false,"description":"Tamano del bullet en px, default 20"}]

notes:
  Wrapper sobre Mantine `Timeline`. Recibe array declarativo de items. El `time` se muestra como texto dimmed debajo del titulo. Items despues de `active` se muestran como inactivos.

code:
  import * as React from 'react'
  import { Timeline, Text } from '@mantine/core'

  interface TimelineItem {
    title: string
    description?: string
    icon?: React.ReactNode
    color?: string
    time?: string
  }

  interface FnTimelineProps {
    items: TimelineItem[]
    active?: number
    bulletSize?: number
  }

  function FnTimeline({
    items,
    active = -1,
    bulletSize = 20,
  }: FnTimelineProps) {
    return (
      <Timeline active={active} bulletSize={bulletSize}>
        {items.map((item, i) => (
          <Timeline.Item
            key={i}
            title={item.title}
            bullet={item.icon}
            color={item.color}
          >
            {item.time && (
              <Text size="xs" c="dimmed" mt={2}>{item.time}</Text>
            )}
            {item.description && (
              <Text size="sm" mt={4}>{item.description}</Text>
            )}
          </Timeline.Item>
        ))}
      </Timeline>
    )
  }

  export { FnTimeline }
  export type { FnTimelineProps, TimelineItem }


id: toast_ts_ui
name: toast
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Toast(props: ToastProps): JSX.Element
tags: ["toast","notification","alert","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/notifications","@mantine/core"]
tests: []
file_path: frontend/functions/ui/toast.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onClose"]
framework: react
variant: ["default","success","error","warning","info"]
content_hash: a4bad4a2b61da0ec3db1060012d6f26c2bfe8d41ff16984910fb80744dc47d12
description:
  Notificaciones temporales con variantes semanticas (success, error, warning, info), iconos automaticos, auto-dismiss y provider con hook useToast.

example:
  ```tsx
  // 1. Envolver la app con el provider
  <ToastProvider position="bottom-right">
    <App />
  </ToastProvider>

  // 2. Usar el hook en cualquier componente
  function MyComponent() {
    const { toast } = useToast()

    return (
      <Button onClick={() => toast({
        variant: "success",
        title: "Guardado",
        description: "Los cambios se guardaron correctamente.",
      })}>
        Guardar
      </Button>
    )
  }

  // Toast con accion
  toast({
    variant: "error",
    title: "Error al guardar",
    description: "Intenta de nuevo.",
    action: <Button size="sm" variant="outline">Reintentar</Button>,
    duration: 0, // persistente hasta cerrar manualmente
  })

  // Toast individual sin provider
  <Toast variant="info" title="Informacion" description="Texto descriptivo" onClose={() => {}} />
  ```

props:
  [{"name":"variant","type":"'default' | 'success' | 'error' | 'warning' | 'info'","required":false,"description":"Variante semantica con icono automatico (default: default)"},{"name":"title","type":"string","required":false,"description":"Titulo de la notificacion"},{"name":"description","type":"string","required":false,"description":"Texto descriptivo secundario"},{"name":"action","type":"React.ReactNode","required":false,"description":"Accion opcional (boton, link) debajo del contenido"},{"name":"onClose","type":"() =\u003e void","required":false,"description":"Callback al cerrar. Muestra el boton X si se provee."},{"name":"duration","type":"number","required":false,"description":"Duracion en ms antes del auto-dismiss (default: 5000, 0 = persistente)"}]

notes:
  Migrado a @mantine/notifications. useToast() delega a notifications.show(). ToastProvider es un passthrough (Mantine maneja el estado globalmente via Notifications en mantine_provider). Toast puede usarse como componente inline con Paper. Variantes mapean a colores Mantine: success→teal, error→red, warning→yellow, info→blue.

code:
  import { notifications } from '@mantine/notifications'
  import { Paper, Text, Group, CloseButton } from '@mantine/core'

  type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'

  interface ToastProps {
    variant?: ToastVariant
    title?: string
    description?: string
    action?: React.ReactNode
    onClose?: () => void
    className?: string
    children?: React.ReactNode
  }

  const variantColors: Record<ToastVariant, string | undefined> = {
    default: undefined,
    success: 'teal',
    error: 'red',
    warning: 'yellow',
    info: 'blue',
  }

  function Toast({ title, description, onClose, className }: ToastProps) {
    return (
      <Paper withBorder shadow="md" p="md" radius="md" className={className}>
        <Group justify="space-between" align="flex-start">
          <div>
            {title && <Text size="sm" fw={600}>{title}</Text>}
            {description && <Text size="sm" c="dimmed">{description}</Text>}
          </div>
          {onClose && <CloseButton size="sm" onClick={onClose} />}
        </Group>
      </Paper>
    )
  }

  const toastVariants = {
    default: 'default',
    success: 'success',
    error: 'error',
    warning: 'warning',
    info: 'info',
  } as const

  interface ToastViewportProps {
    position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
    children?: React.ReactNode
    className?: string
  }

  function ToastViewport({ children }: ToastViewportProps) {
    return <>{children}</>
  }

  type ToastEntry = ToastProps & {
    id: string
    duration?: number
  }

  interface ToastProviderContextValue {
    toasts: ToastEntry[]
    toast: (props: Omit<ToastEntry, 'id'>) => string
    dismiss: (id: string) => void
    dismissAll: () => void
  }

  function ToastProvider({ children }: { children: React.ReactNode; position?: ToastViewportProps['position'] }) {
    return <>{children}</>
  }

  function useToast(): ToastProviderContextValue {
    return {
      toasts: [],
      toast: (props: Omit<ToastEntry, 'id'>) => {
        const id = Math.random().toString(36).slice(2)
        notifications.show({
          id,
          title: props.title,
          message: props.description ?? '',
          color: variantColors[props.variant ?? 'default'],
          autoClose: props.duration === 0 ? false : (props.duration ?? 5000),
        })
        return id
      },
      dismiss: (id: string) => {
        notifications.hide(id)
      },
      dismissAll: () => {
        notifications.cleanQueue()
        notifications.clean()
      },
    }
  }

  export { Toast, ToastProvider, ToastViewport, toastVariants, useToast }
  export type { ToastEntry, ToastProps, ToastViewportProps }

params_schema:
  {"output":"Componente Toast que renderiza notificación temporal con variantes semánticas, auto-dismiss y hook useToast"}


id: tooltip_ts_ui
name: tooltip
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Tooltip(props: TooltipRootProps): JSX.Element
description: Tooltip accesible con posicionamiento automático. Mantine Tooltip con delay configurable.
tags: ["tooltip","overlay","component","ui","help","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/tooltip.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["default"]
content_hash: 950fc5821f3c8aff9e888d8dbc9cfe35ded5dd1987a53d0524ff88eda2195307
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/tooltip.tsx
example:
  ```tsx
  <TooltipProvider>
    <Tooltip>
      <TooltipTrigger>Hover me</TooltipTrigger>
      <TooltipContent>Tooltip text</TooltipContent>
    </Tooltip>
  </TooltipProvider>
  ```

props:
  [{"name":"delayDuration","type":"number","required":false,"description":"Delay en ms antes de mostrar el tooltip (default 300)"}]

notes:
  Exporta 5 subcomponentes: TooltipProvider, Tooltip, TooltipTrigger, TooltipPortal, TooltipContent.
  TooltipProvider y Tooltip son wrappers de compatibilidad (Mantine gestiona delay internamente).
  TooltipPortal es un no-op mantenido por compatibilidad.

code:
  import * as React from 'react'
  import { Tooltip as MantineTooltip } from '@mantine/core'

  function TooltipProvider({ delayDuration: _delayDuration = 300, children }: { delayDuration?: number; children: React.ReactNode }) {
    return <>{children}</>
  }

  const TooltipContext = React.createContext<{
    label: React.ReactNode
    setLabel: (label: React.ReactNode) => void
    side: 'top' | 'right' | 'bottom' | 'left'
    setSide: (side: 'top' | 'right' | 'bottom' | 'left') => void
    offset: number
    setOffset: (offset: number) => void
  }>({
    label: '',
    setLabel: () => {},
    side: 'top',
    setSide: () => {},
    offset: 4,
    setOffset: () => {},
  })

  interface TooltipProps {
    children: React.ReactNode
    delayDuration?: number
  }

  function Tooltip({ children }: TooltipProps) {
    const [label, setLabel] = React.useState<React.ReactNode>('')
    const [side, setSide] = React.useState<'top' | 'right' | 'bottom' | 'left'>('top')
    const [offset, setOffset] = React.useState(4)

    return (
      <TooltipContext.Provider value={{ label, setLabel, side, setSide, offset, setOffset }}>
        {children}
      </TooltipContext.Provider>
    )
  }

  function TooltipTrigger({ children, ...props }: React.ComponentProps<'button'>) {
    const { label, side, offset } = React.useContext(TooltipContext)
    return (
      <MantineTooltip label={label} position={side} offset={offset} withArrow>
        {React.isValidElement(children) ? (
          React.cloneElement(children as React.ReactElement<any>, props)
        ) : (
          <button type="button" data-slot="tooltip-trigger" {...props}>{children}</button>
        )}
      </MantineTooltip>
    )
  }

  function TooltipPortal({ children }: { children: React.ReactNode }) {
    return <>{children}</>
  }

  interface TooltipContentProps extends React.ComponentProps<'div'> {
    sideOffset?: number
    side?: 'top' | 'right' | 'bottom' | 'left'
  }

  function TooltipContent({ children, sideOffset, side = 'top' }: TooltipContentProps) {
    const { setLabel, setSide, setOffset } = React.useContext(TooltipContext)

    React.useEffect(() => {
      setLabel(children)
      setSide(side)
      if (sideOffset !== undefined) setOffset(sideOffset)
    }, [children, side, sideOffset, setLabel, setSide, setOffset])

    return null
  }

  export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger }

params_schema:
  {"output":"Componente Tooltip que renderiza tooltip accesible con posicionamiento automático y delay configurable via Mantine Tooltip"}


id: use_animated_canvas_ts_ui
name: use_animated_canvas
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: useAnimatedCanvas(options: AnimatedCanvasOptions): AnimatedCanvasResult
tags: ["canvas","animation","fps","requestAnimationFrame","hook","realtime","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_animated_canvas.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: []
framework: react
variant: ["default"]
content_hash: 9157ee07fe43dbb9679c63c670e2308b9fa96a41cb0a4e9b85d693d07f92f9da
description:
  Hook React para canvas animado a N fps via requestAnimationFrame. Maneja DPR, resize, throttling, y contador de FPS real.

example:
  ```tsx
  const { canvasRef, renderFpsRef } = useAnimatedCanvas({
    fps: 100,
    draw: (ctx, w, h) => {
      // Dibujar lo que sea a 100fps
      ctx.fillStyle = '#3b82f6'
      ctx.fillRect(0, 0, w * Math.random(), h)
    },
  })

  return <canvas ref={canvasRef} style={{ width: '100%', height: 300 }} />
  ```

props:
  [{"name":"fps","type":"number","required":false,"description":"Target FPS (default 60)"},{"name":"draw","type":"(ctx: CanvasRenderingContext2D, width: number, height: number, frameCount: number) =\u003e void","required":true,"description":"Callback de renderizado"}]

notes:
  - DPR automático: escala el canvas al devicePixelRatio de la pantalla
  - Resize automático: detecta cambios de tamaño via getBoundingClientRect
  - Throttle configurable: rAF corre a ~144fps nativo, el hook filtra a N fps
  - FPS real: `renderFpsRef.current` tiene los FPS medidos (no el target)
  - drawRef pattern: actualiza el callback sin re-crear el loop de animación

code:
  import { useEffect, useRef } from 'react'

  export interface AnimatedCanvasOptions {
    /** Target FPS (default 60). El throttle real es 1000/fps ms. */
    fps?: number
    /** Callback de dibujo. Recibe el canvas 2d context y dimensiones. */
    draw: (ctx: CanvasRenderingContext2D, width: number, height: number, frameCount: number) => void
  }

  export interface AnimatedCanvasResult {
    canvasRef: React.RefObject<HTMLCanvasElement | null>
    /** FPS real de renderizado (actualizado cada segundo) */
    renderFpsRef: React.RefObject<number>
  }

  /**
   * Hook para renderizar un canvas a N fps usando requestAnimationFrame.
   * Maneja DPR, resize, y throttling automático.
   */
  export function useAnimatedCanvas(options: AnimatedCanvasOptions): AnimatedCanvasResult {
    const { fps = 60, draw } = options
    const canvasRef = useRef<HTMLCanvasElement>(null)
    const renderFpsRef = useRef(0)
    const rafRef = useRef(0)
    const lastDrawRef = useRef(0)
    const frameCountRef = useRef(0)
    const fpsTimerRef = useRef(0)
    const drawRef = useRef(draw)

    // Keep draw ref updated without re-subscribing the effect
    drawRef.current = draw

    useEffect(() => {
      const interval = 1000 / fps

      const loop = (now: number) => {
        rafRef.current = requestAnimationFrame(loop)

        if (now - lastDrawRef.current < interval) return
        lastDrawRef.current = now

        // FPS counting
        frameCountRef.current++
        if (now - fpsTimerRef.current >= 1000) {
          renderFpsRef.current = frameCountRef.current
          frameCountRef.current = 0
          fpsTimerRef.current = now
        }

        const canvas = canvasRef.current
        if (!canvas) return

        const rect = canvas.getBoundingClientRect()
        const w = rect.width
        const h = rect.height
        const dpr = window.devicePixelRatio || 1

        const targetW = Math.floor(w * dpr)
        const targetH = Math.floor(h * dpr)

        if (canvas.width !== targetW || canvas.height !== targetH) {
          canvas.width = targetW
          canvas.height = targetH
        }

        const ctx = canvas.getContext('2d')
        if (!ctx) return

        ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
        ctx.clearRect(0, 0, w, h)

        drawRef.current(ctx, w, h, frameCountRef.current)
      }

      rafRef.current = requestAnimationFrame(loop)
      return () => cancelAnimationFrame(rafRef.current)
    }, [fps])

    return { canvasRef, renderFpsRef }
  }

params_schema:
  {"params":[{"name":"options","desc":"Configuración: fps (target), draw (callback de renderizado), opcionalmente DPR"}],"output":"Hook que retorna canvasRef y renderFpsRef para renderizado animado a N fps"}


id: use_wails_event_ts_ui
name: use_wails_event
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: useWailsEvent<T>(opts: UseWailsEventOptions<T>): UseWailsEventResult<T>
tags: ["wails","event","hook","ipc","realtime","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_wails_event.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: ["onEvent"]
framework: react
variant: ["default"]
content_hash: 23f5fefca880d48b8d2fb61a0cb7a544595a43ba7b8e383393ce1d7a7a1a0457
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/use-wails-event.tsx
description:
  Hook para suscripción a eventos Go→TS y emisión TS→Go via Wails runtime. Soporta once, maxCallbacks, emit bidireccional.

example:
  ```tsx
  // Escuchar eventos de Go
  const { lastData, eventCount, emit } = useWailsEvent<PriceUpdate>({
    eventName: 'price:update',
    onEvent: (price) => updateChart(price),
  })

  // Emitir de TS a Go
  emit({ symbol: 'BTC', action: 'subscribe' })
  ```

props:
  [{"name":"eventName","type":"string","required":true,"description":"Nombre del evento Wails"},{"name":"onEvent","type":"(data: T) =\u003e void","required":false,"description":"Callback al recibir evento"},{"name":"once","type":"boolean","required":false,"description":"Solo escuchar una vez"},{"name":"enabled","type":"boolean","required":false,"description":"Habilitar suscripción"}]

notes:
  Exporta también `useWailsEmit()` para emit sin suscripción. Usa `window.runtime.EventsOn/Off/Emit` del Wails runtime.

code:
  import { useCallback, useEffect, useRef, useState } from 'react'

  // Types for Wails runtime (will be available when running in Wails)
  declare global {
    interface Window {
      runtime?: {
        EventsOn: (eventName: string, callback: (...args: unknown[]) => void) => () => void
        EventsOff: (eventName: string, ...additionalEventNames: string[]) => void
        EventsOnce: (eventName: string, callback: (...args: unknown[]) => void) => () => void
        EventsOnMultiple: (eventName: string, callback: (...args: unknown[]) => void, maxCallbacks: number) => () => void
        EventsEmit: (eventName: string, ...data: unknown[]) => void
      }
    }
  }

  export interface UseWailsEventOptions<T> {
    /** Nombre del evento a escuchar */
    eventName: string
    /** Callback cuando llega el evento */
    onEvent?: (data: T) => void
    /** Si está habilitado */
    enabled?: boolean
    /** Solo escuchar una vez */
    once?: boolean
    /** Número máximo de veces a escuchar */
    maxCallbacks?: number
  }

  export interface UseWailsEventResult<T> {
    /** Último dato recibido */
    lastData: T | undefined
    /** Número de eventos recibidos */
    eventCount: number
    /** Emitir un evento */
    emit: (data?: T) => void
    /** Resetear estado */
    reset: () => void
  }

  /**
   * Hook para suscribirse a eventos de Wails
   */
  export function useWailsEvent<T = unknown>({
    eventName,
    onEvent,
    enabled = true,
    once = false,
    maxCallbacks,
  }: UseWailsEventOptions<T>): UseWailsEventResult<T> {
    const [lastData, setLastData] = useState<T | undefined>(undefined)
    const [eventCount, setEventCount] = useState(0)
    const callbackRef = useRef(onEvent)

    // Keep callback ref updated
    useEffect(() => {
      callbackRef.current = onEvent
    }, [onEvent])

    useEffect(() => {
      if (!enabled || !window.runtime) return

      const handleEvent = (...args: unknown[]) => {
        const data = args[0] as T
        setLastData(data)
        setEventCount((c) => c + 1)
        callbackRef.current?.(data)
      }

      let unsubscribe: (() => void) | undefined

      if (once) {
        unsubscribe = window.runtime.EventsOnce(eventName, handleEvent)
      } else if (maxCallbacks !== undefined) {
        unsubscribe = window.runtime.EventsOnMultiple(eventName, handleEvent, maxCallbacks)
      } else {
        unsubscribe = window.runtime.EventsOn(eventName, handleEvent)
      }

      return () => {
        unsubscribe?.()
      }
    }, [eventName, enabled, once, maxCallbacks])

    const emit = useCallback(
      (data?: T) => {
        if (window.runtime) {
          window.runtime.EventsEmit(eventName, data)
        }
      },
      [eventName]
    )

    const reset = useCallback(() => {
      setLastData(undefined)
      setEventCount(0)
    }, [])

    return {
      lastData,
      eventCount,
      emit,
      reset,
    }
  }

  /**
   * Hook para emitir eventos a Wails (sin suscripción)
   */
  export function useWailsEmit() {
    return useCallback((eventName: string, ...data: unknown[]) => {
      if (window.runtime) {
        window.runtime.EventsEmit(eventName, ...data)
      }
    }, [])
  }

params_schema:
  {"params":[{"name":"opts","desc":"Configuración: eventName, onEvent callback, once, enabled para controlar suscripción"}],"output":"Hook que retorna lastData, eventCount, emit y funciones para suscribirse a eventos Wails bidireccionales"}


id: use_wails_mutation_ts_ui
name: use_wails_mutation
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
tags: ["wails","mutation","hook","ipc","optimistic","component","ui"]
uses_functions: ["wails_cache_ts_core","wails_provider_ts_ui"]
uses_types: ["WailsIPC_ts_ui"]
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_wails_mutation.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: ["onSuccess","onError","onSettled"]
framework: react
variant: ["default"]
content_hash: 448f91cfb5425fa5fcfc2d56f1d20b61bb157b9dbbb79ef6fa85af805d9d58c2
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/use-wails-mutation.tsx
signature:
  useWailsMutation<TData, TVariables>(opts: UseWailsMutationOptions<TData, TVariables>): UseWailsMutationResult<TData, TVariables>

description:
  Hook para escrituras IPC Wails con optimistic updates, invalidación automática de queries, retry y callbacks completos.

example:
  ```tsx
  const { mutate, isLoading } = useWailsMutation({
    mutationFn: (user: User) => CreateUser(user),
    invalidateQueries: [['users']],
    onMutate: (user) => {
      // Optimistic: añadir al cache antes de confirmar
      const prev = wailsCache.get<User[]>(['users', 'list'])
      wailsCache.set(['users', 'list'], [...(prev || []), user])
      return { prev }
    },
    onError: (_err, _vars, context) => {
      // Rollback
      wailsCache.set(['users', 'list'], context.prev)
    },
  })
  ```

props:
  [{"name":"mutationFn","type":"(variables: TVariables) =\u003e Promise\u003cTData\u003e","required":true,"description":"Función que ejecuta la mutación via IPC"},{"name":"invalidateQueries","type":"string[][]","required":false,"description":"Query keys a invalidar en éxito"},{"name":"onMutate","type":"(variables: TVariables) =\u003e unknown","required":false,"description":"Optimistic update antes de la mutación"}]

notes:
  `mutate()` es fire-and-forget, `mutateAsync()` retorna Promise. Estado: idle → loading → success/error.

code:
  import { useCallback, useRef, useState } from 'react'
  import { useWailsContext } from './wails_provider'
  import type { MutationOptions } from '../../types/ui/wails_ipc'

  export interface UseWailsMutationOptions<TData, TVariables> extends MutationOptions {
    /** Función que ejecuta la mutación */
    mutationFn: (variables: TVariables) => Promise<TData>
    /** Callback antes de la mutación (optimistic update) */
    onMutate?: (variables: TVariables) => Promise<unknown> | unknown
    /** Callback en éxito */
    onSuccess?: (data: TData, variables: TVariables, context: unknown) => void
    /** Callback en error */
    onError?: (error: Error, variables: TVariables, context: unknown) => void
    /** Callback siempre (éxito o error) */
    onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables, context: unknown) => void
    /** Query keys a invalidar en éxito */
    invalidateQueries?: string[][]
  }

  export interface UseWailsMutationResult<TData, TVariables> {
    /** Ejecutar la mutación */
    mutate: (variables: TVariables) => void
    /** Ejecutar la mutación (async) */
    mutateAsync: (variables: TVariables) => Promise<TData>
    /** Estado de carga */
    isLoading: boolean
    /** Datos del resultado */
    data: TData | undefined
    /** Error si ocurrió */
    error: Error | null
    /** Resetear estado */
    reset: () => void
    /** Si fue exitoso */
    isSuccess: boolean
    /** Si hubo error */
    isError: boolean
    /** Si está idle */
    isIdle: boolean
  }

  export function useWailsMutation<TData, TVariables = void>({
    mutationFn,
    onMutate,
    onSuccess,
    onError,
    onSettled,
    invalidateQueries,
    retry = false,
    retryDelay = 1000,
  }: UseWailsMutationOptions<TData, TVariables>): UseWailsMutationResult<TData, TVariables> {
    const { cache } = useWailsContext()

    const [state, setState] = useState<{
      status: 'idle' | 'loading' | 'success' | 'error'
      data: TData | undefined
      error: Error | null
    }>({
      status: 'idle',
      data: undefined,
      error: null,
    })

    const retryCountRef = useRef(0)

    const executeMutation = useCallback(
      async (variables: TVariables): Promise<TData> => {
        setState((s) => ({ ...s, status: 'loading', error: null }))

        let context: unknown

        try {
          // Optimistic update
          if (onMutate) {
            context = await onMutate(variables)
          }

          const data = await mutationFn(variables)

          // Reset retry count on success
          retryCountRef.current = 0

          setState({
            status: 'success',
            data,
            error: null,
          })

          // Invalidate queries
          if (invalidateQueries) {
            invalidateQueries.forEach((queryKey) => {
              cache.invalidate(queryKey)
            })
          }

          onSuccess?.(data, variables, context)
          onSettled?.(data, null, variables, context)

          return data
        } catch (error) {
          const maxRetries = typeof retry === 'number' ? retry : retry ? 3 : 0

          if (retryCountRef.current < maxRetries) {
            retryCountRef.current += 1
            await new Promise((resolve) => setTimeout(resolve, retryDelay))
            return executeMutation(variables)
          }

          setState((s) => ({
            ...s,
            status: 'error',
            error: error as Error,
          }))

          onError?.(error as Error, variables, context)
          onSettled?.(undefined, error as Error, variables, context)

          throw error
        }
      },
      [mutationFn, onMutate, onSuccess, onError, onSettled, invalidateQueries, cache, retry, retryDelay]
    )

    const mutate = useCallback(
      (variables: TVariables) => {
        executeMutation(variables).catch(() => {})
      },
      [executeMutation]
    )

    const reset = useCallback(() => {
      setState({
        status: 'idle',
        data: undefined,
        error: null,
      })
      retryCountRef.current = 0
    }, [])

    return {
      mutate,
      mutateAsync: executeMutation,
      isLoading: state.status === 'loading',
      data: state.data,
      error: state.error,
      reset,
      isSuccess: state.status === 'success',
      isError: state.status === 'error',
      isIdle: state.status === 'idle',
    }
  }

params_schema:
  {"params":[{"name":"opts","desc":"Configuración: mutationFn, invalidateQueries, onMutate para optimistic updates"}],"output":"Hook que retorna mutate, mutateAsync, isLoading, error para ejecutar escrituras IPC con cache"}


id: use_wails_query_ts_ui
name: use_wails_query
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: useWailsQuery<T>(opts: UseWailsQueryOptions<T>): UseWailsQueryResult<T>
tags: ["wails","query","hook","ipc","cache","component","ui"]
uses_functions: ["wails_cache_ts_core","wails_provider_ts_ui"]
uses_types: ["WailsIPC_ts_ui"]
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_wails_query.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: ["onSuccess","onError"]
framework: react
variant: ["default"]
content_hash: c4352f02934ed80e92fc4753a052848e7066a3da04d67ef2521882c9d90208af
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/use-wails-query.tsx
description:
  Hook React Query-like sobre IPC Wails. Cache automático, refetch por intervalo/foco, retry con backoff, invalidación.

example:
  ```tsx
  const { data, loading, refetch, invalidate } = useWailsQuery({
    queryKey: ['users', 'list'],
    queryFn: () => GetUsers(),       // Wails binding
    refetchInterval: 30000,          // Cada 30s
    staleTime: 5000,                 // Fresh por 5s
    onSuccess: (users) => console.log(`${users.length} users`),
  })
  ```

props:
  [{"name":"queryKey","type":"string[]","required":true,"description":"Key única para cache"},{"name":"queryFn","type":"() =\u003e Promise\u003cT\u003e","required":true,"description":"Función que llama al binding Wails"},{"name":"enabled","type":"boolean","required":false,"description":"Habilitar auto-fetch (default true)"},{"name":"refetchInterval","type":"number","required":false,"description":"Refetch cada N ms"},{"name":"staleTime","type":"number","required":false,"description":"Tiempo antes de considerar datos stale"}]

notes:
  API inspirada en TanStack Query pero sobre IPC Wails (sin HTTP). Cache compartido via WailsProvider o singleton. Refetch automático en window focus.

code:
  import { useCallback, useEffect, useRef, useState } from 'react'
  import { useWailsContext } from './wails_provider'
  import { DEFAULT_QUERY_OPTIONS, type QueryOptions, type QueryState } from '../../types/ui/wails_ipc'

  export interface UseWailsQueryOptions<T> extends QueryOptions {
    /** Key única para identificar esta query en el cache */
    queryKey: string[]
    /** Función que ejecuta la llamada a Wails */
    queryFn: () => Promise<T>
    /** Callback en éxito */
    onSuccess?: (data: T) => void
    /** Callback en error */
    onError?: (error: Error) => void
  }

  export interface UseWailsQueryResult<T> extends QueryState<T> {
    /** Re-ejecutar la query manualmente */
    refetch: () => Promise<T>
    /** Invalidar cache de esta query */
    invalidate: () => void
  }

  export function useWailsQuery<T>({
    queryKey,
    queryFn,
    onSuccess,
    onError,
    ...options
  }: UseWailsQueryOptions<T>): UseWailsQueryResult<T> {
    const { cache, defaultQueryOptions } = useWailsContext()

    // Merge options with defaults
    const opts = {
      ...DEFAULT_QUERY_OPTIONS,
      ...defaultQueryOptions,
      ...options,
    }

    const {
      enabled,
      staleTime,
      refetchInterval,
      refetchOnFocus,
      retry,
      retryDelay,
    } = opts

    const [state, setState] = useState<QueryState<T>>(() => ({
      data: cache.get<T>(queryKey),
      loading: !cache.has(queryKey) && enabled,
      error: null,
      isStale: cache.isStale(queryKey, staleTime),
      lastUpdated: cache.getTimestamp(queryKey),
    }))

    const retryCountRef = useRef(0)
    const mountedRef = useRef(true)

    const fetch = useCallback(async (): Promise<T> => {
      if (!mountedRef.current) throw new Error('Component unmounted')

      setState((s) => ({ ...s, loading: true, error: null }))

      try {
        const data = await queryFn()

        if (!mountedRef.current) throw new Error('Component unmounted')

        cache.set(queryKey, data)
        retryCountRef.current = 0

        setState({
          data,
          loading: false,
          error: null,
          isStale: false,
          lastUpdated: new Date(),
        })

        onSuccess?.(data)
        return data
      } catch (error) {
        if (!mountedRef.current) throw error

        const maxRetries = typeof retry === 'number' ? retry : retry ? 3 : 0

        if (retryCountRef.current < maxRetries) {
          retryCountRef.current += 1
          await new Promise((resolve) => setTimeout(resolve, retryDelay))
          return fetch()
        }

        setState((s) => ({
          ...s,
          loading: false,
          error: error as Error,
        }))

        onError?.(error as Error)
        throw error
      }
    }, [queryKey.join(':'), queryFn, cache, staleTime, retry, retryDelay, onSuccess, onError])

    // Initial fetch
    useEffect(() => {
      mountedRef.current = true

      if (enabled && (!cache.has(queryKey) || cache.isStale(queryKey, staleTime))) {
        fetch().catch(() => {})
      }

      return () => {
        mountedRef.current = false
      }
    }, [enabled, queryKey.join(':'), staleTime])

    // Refetch interval
    useEffect(() => {
      if (!refetchInterval || !enabled) return

      const interval = setInterval(() => {
        fetch().catch(() => {})
      }, refetchInterval)

      return () => clearInterval(interval)
    }, [refetchInterval, enabled, fetch])

    // Refetch on window focus
    useEffect(() => {
      if (!refetchOnFocus || !enabled) return

      const handleFocus = () => {
        if (cache.isStale(queryKey, staleTime)) {
          fetch().catch(() => {})
        }
      }

      window.addEventListener('focus', handleFocus)
      return () => window.removeEventListener('focus', handleFocus)
    }, [refetchOnFocus, enabled, queryKey.join(':'), staleTime, fetch])

    // Subscribe to cache changes
    useEffect(() => {
      return cache.subscribe(queryKey, () => {
        setState((s) => ({
          ...s,
          data: cache.get<T>(queryKey),
          isStale: cache.isStale(queryKey, staleTime),
          lastUpdated: cache.getTimestamp(queryKey),
        }))
      })
    }, [queryKey.join(':'), staleTime])

    const invalidate = useCallback(() => {
      cache.invalidate(queryKey)
    }, [queryKey.join(':')])

    return {
      ...state,
      refetch: fetch,
      invalidate,
    }
  }

params_schema:
  {"params":[{"name":"opts","desc":"Configuración: queryKey para cache, queryFn que ejecuta binding Wails, enabled, refetchInterval, staleTime"}],"output":"Hook que retorna data, loading, error, refetch, invalidate para queries cacheadas vía IPC"}


id: use_wails_stream_ts_ui
name: use_wails_stream
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: useWailsStream<T>(opts: UseWailsStreamOptions<T>): UseWailsStreamResult<T>
tags: ["wails","stream","hook","ipc","realtime","buffer","component","ui"]
uses_functions: ["use_wails_event_ts_ui"]
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_wails_stream.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: ["onData","onComplete","onError"]
framework: react
variant: ["default"]
content_hash: 89ab2e113edf401405c222a9adf7e55ff3d2ea28dce3ecd7fa7ebef65a27992a
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/use-wails-stream.tsx
description:
  Hook para streaming de datos Go→TS con buffer configurable, auto-complete, transform y control start/stop. Incluye useWailsLogs.

example:
  ```tsx
  // Stream de logs en tiempo real
  const { data: logs, isStreaming, start, stop, clear } = useWailsStream<string>({
    streamName: 'app:logs',
    startFn: () => StartLogStream(),
    stopFn: () => StopLogStream(),
    bufferSize: 500,
    autoStart: true,
  })

  // Versión simplificada para logs
  const { data: logs } = useWailsLogs('deploy:logs')
  ```

props:
  [{"name":"streamName","type":"string","required":true,"description":"Nombre del evento de stream"},{"name":"startFn","type":"() =\u003e Promise\u003cvoid\u003e","required":false,"description":"Función Go para iniciar el stream"},{"name":"stopFn","type":"() =\u003e Promise\u003cvoid\u003e","required":false,"description":"Función Go para detener el stream"},{"name":"bufferSize","type":"number","required":false,"description":"Tamaño máximo del buffer (default 1000)"},{"name":"transform","type":"(data: unknown) =\u003e T","required":false,"description":"Transformar datos entrantes"}]

notes:
  Protocolo de stream: Go emite chunks en `{streamName}`, completa con `{streamName}:complete`, errores con `{streamName}:error`. Buffer circular con tamaño configurable.

code:
  import { useCallback, useEffect, useRef, useState } from 'react'
  import { useWailsEvent } from './use_wails_event'

  export interface UseWailsStreamOptions<T> {
    /** Nombre del evento de stream */
    streamName: string
    /** Función para iniciar el stream en Go */
    startFn?: () => Promise<void>
    /** Función para detener el stream en Go */
    stopFn?: () => Promise<void>
    /** Transformar datos entrantes */
    transform?: (data: unknown) => T
    /** Callback por cada chunk */
    onData?: (data: T) => void
    /** Callback cuando el stream termina */
    onComplete?: () => void
    /** Callback en error */
    onError?: (error: Error) => void
    /** Tamaño máximo del buffer */
    bufferSize?: number
    /** Auto-iniciar el stream */
    autoStart?: boolean
  }

  export interface UseWailsStreamResult<T> {
    /** Datos acumulados */
    data: T[]
    /** Último dato recibido */
    lastChunk: T | undefined
    /** Si está activo */
    isStreaming: boolean
    /** Si está cargando (iniciando) */
    isLoading: boolean
    /** Error si ocurrió */
    error: Error | null
    /** Iniciar stream */
    start: () => Promise<void>
    /** Detener stream */
    stop: () => Promise<void>
    /** Limpiar buffer */
    clear: () => void
    /** Número de chunks recibidos */
    chunkCount: number
  }

  /**
   * Hook para manejar streams de datos desde Wails
   * Útil para datos en tiempo real como logs, métricas, etc.
   */
  export function useWailsStream<T = unknown>({
    streamName,
    startFn,
    stopFn,
    transform,
    onData,
    onComplete,
    onError,
    bufferSize = 1000,
    autoStart = false,
  }: UseWailsStreamOptions<T>): UseWailsStreamResult<T> {
    const [data, setData] = useState<T[]>([])
    const [isStreaming, setIsStreaming] = useState(false)
    const [isLoading, setIsLoading] = useState(false)
    const [error, setError] = useState<Error | null>(null)
    const [chunkCount, setChunkCount] = useState(0)

    const lastChunkRef = useRef<T | undefined>(undefined)
    const mountedRef = useRef(true)

    // Listen for stream data
    useWailsEvent<unknown>({
      eventName: streamName,
      enabled: isStreaming,
      onEvent: (rawData) => {
        if (!mountedRef.current) return

        const chunk = transform ? transform(rawData) : (rawData as T)
        lastChunkRef.current = chunk

        setData((prev) => {
          const newData = [...prev, chunk]
          // Mantener buffer limitado
          if (newData.length > bufferSize) {
            return newData.slice(-bufferSize)
          }
          return newData
        })

        setChunkCount((c) => c + 1)
        onData?.(chunk)
      },
    })

    // Listen for stream complete
    useWailsEvent({
      eventName: `${streamName}:complete`,
      enabled: isStreaming,
      onEvent: () => {
        if (!mountedRef.current) return
        setIsStreaming(false)
        onComplete?.()
      },
    })

    // Listen for stream error
    useWailsEvent<{ message: string }>({
      eventName: `${streamName}:error`,
      enabled: isStreaming,
      onEvent: (errorData) => {
        if (!mountedRef.current) return
        const err = new Error(errorData?.message || 'Stream error')
        setError(err)
        setIsStreaming(false)
        onError?.(err)
      },
    })

    const start = useCallback(async () => {
      if (isStreaming || isLoading) return

      setIsLoading(true)
      setError(null)

      try {
        if (startFn) {
          await startFn()
        }
        setIsStreaming(true)
      } catch (err) {
        const error = err as Error
        setError(error)
        onError?.(error)
      } finally {
        setIsLoading(false)
      }
    }, [isStreaming, isLoading, startFn, onError])

    const stop = useCallback(async () => {
      if (!isStreaming) return

      try {
        if (stopFn) {
          await stopFn()
        }
        setIsStreaming(false)
      } catch (err) {
        const error = err as Error
        setError(error)
        onError?.(error)
      }
    }, [isStreaming, stopFn, onError])

    const clear = useCallback(() => {
      setData([])
      setChunkCount(0)
      lastChunkRef.current = undefined
    }, [])

    // Auto-start
    useEffect(() => {
      if (autoStart) {
        start()
      }
    }, []) // eslint-disable-line react-hooks/exhaustive-deps

    // Cleanup
    useEffect(() => {
      mountedRef.current = true
      return () => {
        mountedRef.current = false
      }
    }, [])

    return {
      data,
      lastChunk: lastChunkRef.current,
      isStreaming,
      isLoading,
      error,
      start,
      stop,
      clear,
      chunkCount,
    }
  }

  /**
   * Hook simplificado para logs en tiempo real
   */
  export function useWailsLogs(eventName: string = 'logs') {
    return useWailsStream<string>({
      streamName: eventName,
      transform: (data) => (typeof data === 'string' ? data : JSON.stringify(data)),
      bufferSize: 500,
    })
  }

params_schema:
  {"params":[{"name":"opts","desc":"Configuración: streamName, startFn/stopFn para control, bufferSize, transform para procesamiento"}],"output":"Hook que retorna data (array bufferado), isStreaming, start, stop, clear para streaming Go→TS"}


id: wails_provider_ts_ui
name: wails_provider
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
tags: ["wails","provider","context","ipc","component","ui"]
uses_functions: ["wails_cache_ts_core"]
uses_types: ["WailsIPC_ts_ui"]
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/wails_provider.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: []
framework: react
variant: ["default"]
content_hash: 73c511b9e730dfc299b734e3a7f5d222d6727459d90c70d1af1b1d4dfeca9eae
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/wails-provider.tsx
signature:
  WailsProvider(props: { children: ReactNode; cache?: WailsCache; defaultQueryOptions?: QueryOptions }): JSX.Element

description:
  Provider React para IPC Wails con cache context, opciones default y fallback a singleton. Exporta useWailsContext y useWailsCache.

example:
  ```tsx
  <WailsProvider defaultQueryOptions={{ staleTime: 5000, retry: 2 }}>
    <App />
  </WailsProvider>
  ```

props:
  [{"name":"cache","type":"WailsCache","required":false,"description":"Cache custom (default: singleton global)"},{"name":"defaultQueryOptions","type":"Partial\u003cQueryOptions\u003e","required":false,"description":"Opciones default para todas las queries"},{"name":"children","type":"ReactNode","required":true,"description":"App content"}]

notes:
  Sin provider, los hooks hacen fallback al singleton `wailsCache`. El provider solo es necesario para cache custom o opciones globales.

code:
  import { createContext, useContext, useMemo, type ReactNode } from 'react'
  import { WailsCache, wailsCache } from '../core/wails_cache'
  import type { QueryOptions, MutationOptions } from '../../types/ui/wails_ipc'

  interface WailsContextValue {
    cache: WailsCache
    defaultQueryOptions: Partial<QueryOptions>
    defaultMutationOptions: Partial<MutationOptions>
  }

  const WailsContext = createContext<WailsContextValue | null>(null)

  export interface WailsProviderProps {
    children: ReactNode
    /** Usar un cache custom en lugar del singleton */
    cache?: WailsCache
    /** Opciones por defecto para queries */
    defaultQueryOptions?: Partial<QueryOptions>
    /** Opciones por defecto para mutations */
    defaultMutationOptions?: Partial<MutationOptions>
  }

  export function WailsProvider({
    children,
    cache,
    defaultQueryOptions = {},
    defaultMutationOptions = {},
  }: WailsProviderProps) {
    const value = useMemo<WailsContextValue>(
      () => ({
        cache: cache ?? wailsCache,
        defaultQueryOptions,
        defaultMutationOptions,
      }),
      [cache, defaultQueryOptions, defaultMutationOptions]
    )

    return (
      <WailsContext.Provider value={value}>
        {children}
      </WailsContext.Provider>
    )
  }

  export function useWailsContext(): WailsContextValue {
    const context = useContext(WailsContext)
    if (!context) {
      // Fallback to singleton cache if no provider
      return {
        cache: wailsCache,
        defaultQueryOptions: {},
        defaultMutationOptions: {},
      }
    }
    return context
  }

  export function useWailsCache(): WailsCache {
    return useWailsContext().cache
  }

params_schema:
  {"output":"Componente WailsProvider que proporciona context de cache y opciones default para hooks Wails"}


id: build_guide_prompt_kt_core
name: build_guide_prompt
kind: function
lang: kt
domain: core
version: 1.0.0
purity: pure
tags: ["llm","prompt","guide","location","poi","chat","openai","ollama","voice"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: kotlin/functions/core/build_guide_prompt.kt
created_at: 2026-04-06T16:55:26Z
updated_at: 2026-04-06T16:55:26Z
props: []
emits: []
variant: []
content_hash: 8d279fc604f32bdf5a717f433eaca2e83e26a12882f5d8a63e450349a1cdf82e
signature:
  fun buildGuidePrompt(location: Map<String, String>, pois: List<Map<String, Any>>, interests: List<Interest>, userQuery: String = "", lang: String = "es"): List<ChatMessage>

description:
  Construye un prompt contextual para el LLM como guía local combinando ubicación actual, POIs cercanos rankeados e intereses del usuario. Puerto Kotlin de build_guide_prompt_py_core. Retorna exactamente 2 mensajes: system y user en formato OpenAI/Ollama.

example:
  ```kotlin
  val location = mapOf(
      "display_name" to "Calle Mayor, 1, Madrid, España",
      "neighbourhood" to "Centro",
      "city" to "Madrid"
  )

  val pois = listOf(
      mapOf(
          "name" to "Museo del Prado",
          "category" to "museum",
          "score" to 0.95,
          "matched_interests" to listOf("arte", "historia")
      )
  )

  val interests = listOf(
      Interest(name = "arte", keywords = listOf("pintura", "escultura"), weight = 1.0),
      Interest(name = "historia", keywords = listOf("medieval", "siglo XIX"), weight = 0.8)
  )

  val messages = buildGuidePrompt(
      location = location,
      pois = pois,
      interests = interests,
      userQuery = "¿Cuándo abre el Prado?",
      lang = "es"
  )

  // messages[0].role == "system"
  // messages[1].role == "user"
  // messages[1].content contiene "Museo del Prado (museum) — relevante para: arte, historia"
  ```

notes:
  Puerto directo de `build_guide_prompt_py_core` al idioma Kotlin.

  Define dos data classes auxiliares en el mismo archivo:
  - `Interest(name, keywords, weight)` — interés del usuario
  - `ChatMessage(role, content)` — mensaje en formato OpenAI/Ollama

  Comportamiento idéntico al original Python:
  - `city` se extrae con fallback: `city` → `town` → `village` → "tu zona"
  - `neighbourhood` incluye fallback a `suburb`
  - Máximo 5 POIs en el user message
  - Omite línea de barrio si neighbourhood y ciudad están vacíos
  - Omite sección POIs si la lista está vacía
  - Omite `display_name` si está vacío

  Función pura: sin I/O, sin estado mutable, determinista.

code:
  package core

  /**
   * Representa un interes del usuario con nombre, keywords y peso.
   */
  data class Interest(
      val name: String,
      val keywords: List<String> = emptyList(),
      val weight: Double = 1.0
  )

  /**
   * Mensaje en formato OpenAI/Ollama para conversaciones con LLMs.
   */
  data class ChatMessage(
      val role: String,  // "system" | "user" | "assistant"
      val content: String
  )

  /**
   * buildGuidePrompt construye un prompt contextual para el LLM como guía local.
   *
   * Combina la ubicación actual del usuario, POIs cercanos rankeados por relevancia
   * e intereses del usuario para generar mensajes en formato OpenAI/Ollama.
   *
   * @param location Mapa con info de ubicación (nominatim reverse geocode).
   *                 Campos esperados: display_name, street, neighbourhood, city, country, etc.
   * @param pois Lista de POIs rankeados. Cada mapa contiene: name, category, score,
   *             matched_interests (List<String>), lat, lon, tags. Ya filtrados y ordenados.
   * @param interests Lista de intereses del usuario.
   * @param userQuery Pregunta opcional del usuario. Si está vacía, se usa un default.
   * @param lang Idioma para la respuesta del LLM (ej: "es", "en", "fr").
   * @return Lista de dos ChatMessage: [system, user].
   */
  fun buildGuidePrompt(
      location: Map<String, String>,
      pois: List<Map<String, Any>>,
      interests: List<Interest>,
      userQuery: String = "",
      lang: String = "es"
  ): List<ChatMessage> {
      // Extraer nombre de intereses para el system prompt
      val interestsStr = if (interests.isNotEmpty()) {
          interests.filter { it.name.isNotEmpty() }.joinToString(", ") { it.name }
              .ifEmpty { "lugares de interés general" }
      } else {
          "lugares de interés general"
      }

      // Extraer ciudad para el system prompt
      val city = location["city"]?.takeIf { it.isNotEmpty() }
          ?: location["town"]?.takeIf { it.isNotEmpty() }
          ?: location["village"]?.takeIf { it.isNotEmpty() }
          ?: "tu zona"

      // Construir system message
      val systemContent = buildString {
          append("Eres un guía local experto y amigable. El usuario está caminando por $city ")
          append("y le interesan: $interestsStr.\n")
          append("Tu objetivo es informar sobre lo que hay cerca, dar contexto histórico/cultural ")
          append("relevante a sus intereses, y sugerir qué visitar.\n")
          append("Responde en $lang. Sé conciso (máximo 3-4 frases) y conversacional, ")
          append("como si caminaras junto al usuario.\n")
          append("Si no hay POIs relevantes, comenta sobre el barrio o zona.")
      }

      // Construir user message
      val userParts = mutableListOf<String>()

      // Ubicación
      val displayName = location["display_name"] ?: ""
      if (displayName.isNotEmpty()) {
          userParts.add("📍 Estoy en: $displayName")
      }

      val neighbourhood = location["neighbourhood"]?.takeIf { it.isNotEmpty() }
          ?: location["suburb"]?.takeIf { it.isNotEmpty() }
          ?: ""
      val cityLabel = location["city"]?.takeIf { it.isNotEmpty() }
          ?: location["town"]?.takeIf { it.isNotEmpty() }
          ?: location["village"]?.takeIf { it.isNotEmpty() }
          ?: ""
      val locationLineParts = listOf(neighbourhood, cityLabel).filter { it.isNotEmpty() }
      if (locationLineParts.isNotEmpty()) {
          userParts.add("Barrio: ${locationLineParts.joinToString(", ")}")
      }

      // POIs (máximo 5)
      val topPois = pois.take(5)
      if (topPois.isNotEmpty()) {
          userParts.add("")
          userParts.add("Lugares cercanos que me pueden interesar:")
          for (poi in topPois) {
              val name = poi["name"] as? String ?: "Sin nombre"
              val category = poi["category"] as? String ?: ""
              @Suppress("UNCHECKED_CAST")
              val matched = poi["matched_interests"] as? List<String> ?: emptyList()
              val matchedStr = matched.joinToString(", ")

              val poiLine = buildString {
                  append("- $name")
                  if (category.isNotEmpty()) append(" ($category)")
                  if (matchedStr.isNotEmpty()) append(" — relevante para: $matchedStr")
              }
              userParts.add(poiLine)
          }
      }

      // Query del usuario
      userParts.add("")
      val trimmedQuery = userQuery.trim()
      userParts.add(if (trimmedQuery.isNotEmpty()) trimmedQuery else "¿Qué me puedes contar de esta zona?")

      val userContent = userParts.joinToString("\n")

      return listOf(
          ChatMessage(role = "system", content = systemContent),
          ChatMessage(role = "user", content = userContent)
      )
  }

params_schema:
  {"params":[{"name":"location","desc":"Mapa con info de ubicación de nominatim reverse geocode. Campos: display_name, neighbourhood, suburb, city, town, village, country, etc."},{"name":"pois","desc":"Lista de POIs rankeados (máx 5 se usan). Cada mapa: name, category, score, matched_interests (List\u003cString\u003e), lat, lon."},{"name":"interests","desc":"Lista de objetos Interest con name, keywords y weight. Determina el foco del sistema prompt."},{"name":"userQuery","desc":"Pregunta libre del usuario. Si vacía, se usa '¿Qué me puedes contar de esta zona?' como default."},{"name":"lang","desc":"Código de idioma para la respuesta del LLM (ej: 'es', 'en', 'fr')."}],"output":"Lista de exactamente 2 ChatMessage: [system, user]. System describe el rol de guía con ciudad e intereses. User incluye ubicación GPS, barrio, POIs relevantes y la query del usuario."}


id: match_pois_to_interests_kt_core
name: match_pois_to_interests
kind: function
lang: kt
domain: core
version: 1.0.0
purity: pure
tags: ["poi","scoring","ranking","geolocation","interests","voice-guide","osm"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: kotlin/functions/core/match_pois_to_interests.kt
created_at: 2026-04-06T16:55:12Z
updated_at: 2026-04-06T16:55:12Z
props: []
emits: []
variant: []
content_hash: 699311b65d87e8c49894dcf3061430eb8ef8e4af74021a9ca3bbf27744741e9b
signature:
  fun matchPoisToInterests(pois: List<Map<String, Any>>, interests: List<Interest>, maxResults: Int = 10): List<ScoredPOI>

description:
  Filtra y rankea una lista de POIs segun un perfil de intereses del usuario. Calcula un score por categoria (0.5), nombre (0.3) y tags (0.2), multiplicado por el weight del interes. Retorna los top maxResults POIs con score > 0, enriquecidos con score y matchedInterests.

example:
  ```kotlin
  val interests = listOf(
      Interest(name = "gastronomia", keywords = listOf("restaurant", "cafe", "bar"), weight = 1.5),
      Interest(name = "cultura", keywords = listOf("museum", "historic", "monument"), weight = 1.0)
  )

  val pois = listOf(
      mapOf(
          "id" to 1L,
          "lat" to 40.416775,
          "lon" to -3.703790,
          "name" to "Museo del Prado",
          "category" to "museum",
          "tags" to mapOf("tourism" to "museum", "name" to "Museo Nacional del Prado")
      ),
      mapOf(
          "id" to 2L,
          "lat" to 40.415,
          "lon" to -3.700,
          "name" to "Cafe Central",
          "category" to "cafe",
          "tags" to mapOf("amenity" to "cafe")
      )
  )

  val results = matchPoisToInterests(pois, interests, maxResults = 5)
  // results[0].name == "Museo del Prado", score == 1.0  (categoria 0.5 + tags 0.2 = 0.7 × 1.0, name "museo" no matchea "museum" como substring de "museo del prado"... wait: "museum" no esta en "museo del prado")
  // results[0].matchedInterests == ["cultura"]
  // results[1].name == "Cafe Central", score == 1.2  (categoria 0.5 × 1.5)
  ```

notes:
  - Puerto exacto de `match_pois_to_interests_py_core` (Python) al stack Kotlin.
  - Los POIs se reciben como `List<Map<String, Any>>` porque provienen de JSON parseado (sin schema fijo).
  - El id se casteá con fallback `Int -> Long` para compatibilidad con parsers JSON que usan Int por defecto.
  - El score se redondea a 6 decimales para paridad con `round(score, 6)` de Python.
  - Funcion pura: no hace I/O, no muta los inputs, determinista.
  - Los tipos `Interest` y `ScoredPOI` se definen en el mismo archivo para mantener el paquete `core` autocontenido.

documentation:
  ## Algoritmo de scoring

  Para cada POI, por cada interés:

  1. **Category match** (+0.5): `poi.category.lowercase() == keyword` (comparación exacta)
  2. **Name match** (+0.3): `keyword in poi.name.lowercase()` (substring, case-insensitive)
  3. **Tags match** (+0.2): `keyword in tagValues.lowercase()` donde `tagValues = tags.values.joinToString(" ")`

  El subtotal del interés se multiplica por `interest.weight` y se acumula en `totalScore`.

  Solo se incluye el interés en `matchedInterests` si su `interestScore > 0`.

code:
  package core

  /**
   * Represents a user interest with associated keywords and a relevance weight.
   *
   * @param name Human-readable label for the interest (e.g. "gastronomia").
   * @param keywords Terms used to match against POI category, name and tags.
   * @param weight Multiplier applied to the raw score for this interest. Defaults to 1.0.
   */
  data class Interest(
      val name: String,
      val keywords: List<String>,
      val weight: Double = 1.0
  )

  /**
   * A POI enriched with a relevance score and the list of interests it matched.
   *
   * @param id Unique identifier from the source data.
   * @param lat Latitude in decimal degrees.
   * @param lon Longitude in decimal degrees.
   * @param name Human-readable name of the place.
   * @param category OSM-style category string (e.g. "restaurant", "museum").
   * @param tags Arbitrary key-value metadata from the source (OSM tags, etc.).
   * @param score Aggregated relevance score (sum of per-interest scores × weights).
   * @param matchedInterests Names of the interests that contributed to the score.
   */
  data class ScoredPOI(
      val id: Long,
      val lat: Double,
      val lon: Double,
      val name: String,
      val category: String,
      val tags: Map<String, String>,
      val score: Double,
      val matchedInterests: List<String>
  )

  /**
   * Filters and ranks a list of POIs according to a user interest profile.
   *
   * Scoring per interest (weights applied to each sub-score):
   *   - Category match : +0.5 if `poi["category"]` equals any keyword (exact, case-insensitive)
   *   - Name match     : +0.3 if any keyword is a substring of `poi["name"]` (case-insensitive)
   *   - Tags match     : +0.2 if any keyword appears in any tag value (case-insensitive)
   *
   * The per-interest score is multiplied by [Interest.weight] before being added to the total.
   * Multiple interests accumulate. Only POIs with total score > 0 are returned.
   *
   * @param pois Raw POI maps from parsed JSON. Expected keys: "id" (Long), "lat" (Double),
   *             "lon" (Double), "name" (String), "category" (String), "tags" (Map<String,String>).
   * @param interests User interest profile to match against.
   * @param maxResults Maximum number of results to return. Defaults to 10.
   * @return Top [maxResults] POIs with score > 0, sorted by score descending.
   *         Empty list if no POI matches any interest.
   */
  fun matchPoisToInterests(
      pois: List<Map<String, Any>>,
      interests: List<Interest>,
      maxResults: Int = 10
  ): List<ScoredPOI> {
      val scored = mutableListOf<ScoredPOI>()

      for (poi in pois) {
          val category = (poi["category"] as? String ?: "").lowercase()
          val name = (poi["name"] as? String ?: "").lowercase()

          @Suppress("UNCHECKED_CAST")
          val tags = (poi["tags"] as? Map<String, String>) ?: emptyMap()
          val tagValues = tags.values.joinToString(" ").lowercase()

          var totalScore = 0.0
          val matched = mutableListOf<String>()

          for (interest in interests) {
              val keywords = interest.keywords.map { it.lowercase() }
              var interestScore = 0.0

              // Category match: 0.5 if poi category equals any keyword (exact)
              if (keywords.any { it == category }) {
                  interestScore += 0.5
              }

              // Name match: 0.3 if any keyword is a substring of poi name
              if (keywords.any { it in name }) {
                  interestScore += 0.3
              }

              // Tags match: 0.2 if any keyword appears in any tag value
              if (keywords.any { it in tagValues }) {
                  interestScore += 0.2
              }

              if (interestScore > 0.0) {
                  totalScore += interestScore * interest.weight
                  matched.add(interest.name)
              }
          }

          if (totalScore > 0.0) {
              scored.add(
                  ScoredPOI(
                      id = (poi["id"] as? Long) ?: (poi["id"] as? Int)?.toLong() ?: 0L,
                      lat = (poi["lat"] as? Double) ?: 0.0,
                      lon = (poi["lon"] as? Double) ?: 0.0,
                      name = poi["name"] as? String ?: "",
                      category = poi["category"] as? String ?: "",
                      tags = tags,
                      score = Math.round(totalScore * 1_000_000.0) / 1_000_000.0,
                      matchedInterests = matched.toList()
                  )
              )
          }
      }

      return scored
          .sortedByDescending { it.score }
          .take(maxResults)
  }

params_schema:
  {"params":[{"name":"pois","desc":"Lista de POIs parseados de JSON. Cada mapa contiene: id (Long), lat (Double), lon (Double), name (String), category (String), tags (Map\u003cString,String\u003e)."},{"name":"interests","desc":"Perfil de intereses del usuario. Cada Interest tiene name, keywords (lista de terminos de busqueda) y weight (multiplicador de score, default 1.0)."},{"name":"maxResults","desc":"Numero maximo de POIs a retornar, ordenados por score descendente. Default 10."}],"output":"Lista de ScoredPOI con score \u003e 0, ordenados por score descendente, hasta maxResults elementos. Lista vacia si ningun POI coincide con algun interes."}


id: nominatim_reverse_geocode_kt_infra
name: nominatim_reverse_geocode
kind: function
lang: kt
domain: infra
version: 1.0.0
purity: impure
signature: fun nominatimReverseGeocode(lat: Double, lon: Double, lang: String = "es"): GeocodedLocation
tags: ["geocoding","nominatim","openstreetmap","location","address","infra","android","kotlin"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["org.json.JSONObject","java.net.HttpURLConnection","java.net.URL"]
tests: []
file_path: kotlin/functions/infra/nominatim_reverse_geocode.kt
created_at: 2026-04-06T16:54:48Z
updated_at: 2026-04-06T16:54:48Z
props: []
emits: []
variant: []
content_hash: fe6287d2206952a5ec5f9e67691cf9a5f51ec1ec510c4e96739a24797b1c2352
description:
  Reverse geocoding usando Nominatim (OpenStreetMap). Convierte coordenadas lat/lon a una dirección estructurada (calle, ciudad, país, etc.) para Android/Kotlin sin dependencias externas.

example:
  ```kotlin
  val loc = nominatimReverseGeocode(40.4168, -3.7038)
  println(loc.city)        // "Madrid"
  println(loc.street)      // "Calle Mayor"
  println(loc.country)     // "España"
  println(loc.displayName) // cadena completa de Nominatim
  ```

notes:
  Función paralela a `nominatim_reverse_geocode_py_infra` pero adaptada para Android/Kotlin.
  No llamar desde el hilo principal de Android — usar `Dispatchers.IO` o un `ExecutorService`.
  El campo `zoom=18` maximiza el detalle de la dirección (nivel de número de portal).

documentation:
  ## Comportamiento

  - Realiza GET a `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=...&lon=...&accept-language=...&zoom=18`.
  - Cabecera `User-Agent: fn_registry/1.0` obligatoria por política de uso de Nominatim.
  - Timeout de 5 segundos tanto para conexión como para lectura.
  - Usa solo `java.net.HttpURLConnection` y `org.json.JSONObject` — disponibles en el Android SDK sin dependencias externas.
  - Resolución de `city`: prueba `address.city` → `address.town` → `address.village` → `""`.
  - Resolución de `neighbourhood`: prueba `address.neighbourhood` → `address.suburb` → `""`.
  - Lanza `RuntimeException` si el HTTP status no es 200 o si falla la lectura de la respuesta.

code:
  package infra

  import org.json.JSONObject
  import java.net.HttpURLConnection
  import java.net.URL

  data class GeocodedLocation(
      val displayName: String,
      val street: String,
      val houseNumber: String,
      val neighbourhood: String,
      val city: String,
      val state: String,
      val country: String,
      val postcode: String,
      val lat: Double,
      val lon: Double,
      val osmType: String,
      val osmId: Long
  )

  /**
   * nominatimReverseGeocode — Convierte coordenadas lat/lon a una dirección estructurada
   * usando la API pública de Nominatim (OpenStreetMap).
   *
   * @param lat  Latitud en grados decimales.
   * @param lon  Longitud en grados decimales.
   * @param lang Código de idioma ISO 639-1 para la respuesta. Por defecto "es".
   * @return     GeocodedLocation con los campos normalizados de la dirección.
   * @throws     RuntimeException si la petición HTTP falla o el servidor retorna error.
   */
  fun nominatimReverseGeocode(lat: Double, lon: Double, lang: String = "es"): GeocodedLocation {
      val url = URL(
          "https://nominatim.openstreetmap.org/reverse" +
              "?format=jsonv2" +
              "&lat=$lat" +
              "&lon=$lon" +
              "&accept-language=$lang" +
              "&zoom=18"
      )

      val conn = url.openConnection() as HttpURLConnection
      conn.setRequestProperty("User-Agent", "fn_registry/1.0")
      conn.connectTimeout = 5_000
      conn.readTimeout = 5_000
      conn.requestMethod = "GET"

      val responseCode = conn.responseCode
      if (responseCode != HttpURLConnection.HTTP_OK) {
          conn.disconnect()
          throw RuntimeException(
              "nominatimReverseGeocode: HTTP $responseCode para lat=$lat, lon=$lon"
          )
      }

      val body = try {
          conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
      } catch (e: Exception) {
          conn.disconnect()
          throw RuntimeException(
              "nominatimReverseGeocode: error leyendo respuesta — ${e.message}", e
          )
      } finally {
          conn.disconnect()
      }

      val data = JSONObject(body)
      val address = data.optJSONObject("address") ?: JSONObject()

      val city = when {
          address.has("city") && address.getString("city").isNotEmpty() ->
              address.getString("city")
          address.has("town") && address.getString("town").isNotEmpty() ->
              address.getString("town")
          address.has("village") && address.getString("village").isNotEmpty() ->
              address.getString("village")
          else -> ""
      }

      val neighbourhood = when {
          address.has("neighbourhood") && address.getString("neighbourhood").isNotEmpty() ->
              address.getString("neighbourhood")
          address.has("suburb") && address.getString("suburb").isNotEmpty() ->
              address.getString("suburb")
          else -> ""
      }

      return GeocodedLocation(
          displayName = data.optString("display_name", ""),
          street      = address.optString("road", ""),
          houseNumber = address.optString("house_number", ""),
          neighbourhood = neighbourhood,
          city        = city,
          state       = address.optString("state", ""),
          country     = address.optString("country", ""),
          postcode    = address.optString("postcode", ""),
          lat         = data.optString("lat", lat.toString()).toDoubleOrNull() ?: lat,
          lon         = data.optString("lon", lon.toString()).toDoubleOrNull() ?: lon,
          osmType     = data.optString("osm_type", ""),
          osmId       = data.optLong("osm_id", 0L)
      )
  }

params_schema:
  {"params":[{"name":"lat","desc":"Latitud en grados decimales (-90.0 a 90.0)."},{"name":"lon","desc":"Longitud en grados decimales (-180.0 a 180.0)."},{"name":"lang","desc":"Código de idioma ISO 639-1 para la respuesta de Nominatim. Por defecto \"es\" (español)."}],"output":"GeocodedLocation con los campos normalizados: displayName, street, houseNumber, neighbourhood, city, state, country, postcode, lat, lon, osmType, osmId."}


id: ollama_chat_kt_infra
name: ollama_chat
kind: function
lang: kt
domain: infra
version: 1.0.0
purity: impure
tags: ["ollama","llm","chat","inference","http","android","kotlin"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["org.json.JSONObject","org.json.JSONArray","java.net.HttpURLConnection","java.net.URL"]
tests: []
file_path: kotlin/functions/infra/ollama_chat.kt
created_at: 2026-04-06T16:55:18Z
updated_at: 2026-04-06T16:55:18Z
props: []
emits: []
variant: []
content_hash: 1b6dca9db9ab40f3e95b6c417a9a3dba13872627edcb4caf6103d55017ce984e
signature:
  fun ollamaChat(messages: List<Map<String, String>>, model: String = "llama3.1:8b", baseUrl: String = "http://localhost:11434", temperature: Double = 0.7, maxTokens: Int = 1024): OllamaChatResponse

description:
  Envía una solicitud de chat completion a un servidor Ollama local. Retorna el contenido generado junto a métricas de duración y tokens evaluados.

example:
  ```kotlin
  val messages = listOf(
      mapOf("role" to "system", "content" to "Eres un asistente útil."),
      mapOf("role" to "user", "content" to "¿Cuál es la capital de Francia?")
  )

  val response = ollamaChat(
      messages = messages,
      model = "llama3.1:8b",
      temperature = 0.3
  )

  println(response.content)          // "La capital de Francia es París."
  println(response.totalDurationMs)  // 1234
  println(response.evalCount)        // 42
  ```

notes:
  Usa `java.net.HttpURLConnection` y `org.json.JSONObject` del Android SDK — sin dependencias externas adicionales. Timeout de 60 segundos tanto en conexión como en lectura.

  La respuesta de Ollama incluye `total_duration` en nanosegundos; se convierte a milisegundos dividiendo entre 1_000_000.

  Si Ollama no está corriendo lanza `RuntimeException("Ollama no está corriendo en $baseUrl")`. Si el servidor responde con un código HTTP distinto de 200, lanza `RuntimeException` con el código y el cuerpo del error.

  El campo `stream` se fija a `false` para recibir la respuesta completa en una sola llamada.
  ```
  ---

code:
  package infra

  import org.json.JSONArray
  import org.json.JSONObject
  import java.io.ConnectException
  import java.net.HttpURLConnection
  import java.net.URL

  data class OllamaChatResponse(
      val content: String,
      val model: String,
      val totalDurationMs: Long,
      val evalCount: Int
  )

  /**
   * Sends a chat completion request to a local Ollama instance.
   *
   * POSTs to `$baseUrl/api/chat` with the given messages and returns the
   * generated content together with timing and token-count metrics.
   *
   * @param messages  List of role/content maps, e.g. [{"role":"user","content":"hi"}].
   * @param model     Ollama model tag to use.
   * @param baseUrl   Base URL of the Ollama server.
   * @param temperature  Sampling temperature (0.0 – 1.0).
   * @param maxTokens    Maximum number of tokens to generate.
   * @return OllamaChatResponse with content, model name, duration and eval count.
   * @throws RuntimeException if Ollama is not reachable or the server returns an error.
   */
  fun ollamaChat(
      messages: List<Map<String, String>>,
      model: String = "llama3.1:8b",
      baseUrl: String = "http://localhost:11434",
      temperature: Double = 0.7,
      maxTokens: Int = 1024
  ): OllamaChatResponse {
      val messagesArray = JSONArray().apply {
          for (msg in messages) {
              put(JSONObject(msg as Map<*, *>))
          }
      }

      val options = JSONObject().apply {
          put("temperature", temperature)
          put("num_predict", maxTokens)
      }

      val body = JSONObject().apply {
          put("model", model)
          put("messages", messagesArray)
          put("stream", false)
          put("options", options)
      }.toString().toByteArray(Charsets.UTF_8)

      val url = URL("$baseUrl/api/chat")
      val conn: HttpURLConnection

      try {
          conn = url.openConnection() as HttpURLConnection
      } catch (e: ConnectException) {
          throw RuntimeException("Ollama no está corriendo en $baseUrl", e)
      }

      try {
          conn.requestMethod = "POST"
          conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
          conn.setRequestProperty("Accept", "application/json")
          conn.connectTimeout = 60_000
          conn.readTimeout = 60_000
          conn.doOutput = true

          try {
              conn.outputStream.use { it.write(body) }
          } catch (e: ConnectException) {
              throw RuntimeException("Ollama no está corriendo en $baseUrl", e)
          }

          val statusCode = conn.responseCode
          if (statusCode != HttpURLConnection.HTTP_OK) {
              val errorBody = conn.errorStream?.bufferedReader()?.readText() ?: ""
              throw RuntimeException("Ollama devolvió HTTP $statusCode: $errorBody")
          }

          val responseText = conn.inputStream.bufferedReader().readText()
          val json = JSONObject(responseText)

          val content = json.getJSONObject("message").getString("content")
          val returnedModel = json.optString("model", model)
          val totalDurationNs = json.optLong("total_duration", 0L)
          val evalCount = json.optInt("eval_count", 0)

          return OllamaChatResponse(
              content = content,
              model = returnedModel,
              totalDurationMs = totalDurationNs / 1_000_000L,
              evalCount = evalCount
          )
      } finally {
          conn.disconnect()
      }
  }

params_schema:
  {"params":[{"name":"messages","desc":"Lista de mapas role/content en formato OpenAI/Ollama. Ej: [{\"role\":\"user\",\"content\":\"Hola\"}]."},{"name":"model","desc":"Tag del modelo Ollama a usar. Por defecto 'llama3.1:8b'."},{"name":"baseUrl","desc":"URL base del servidor Ollama. Por defecto 'http://localhost:11434'."},{"name":"temperature","desc":"Temperatura de muestreo entre 0.0 (determinista) y 1.0 (creativo). Por defecto 0.7."},{"name":"maxTokens","desc":"Número máximo de tokens a generar. Por defecto 1024."}],"output":"OllamaChatResponse con content (texto generado), model (nombre del modelo), totalDurationMs (duración total en milisegundos) y evalCount (tokens evaluados)."}


id: overpass_nearby_pois_kt_infra
name: overpass_nearby_pois
kind: function
lang: kt
domain: infra
version: 1.0.0
purity: impure
tags: ["overpass","openstreetmap","osm","poi","geospatial","location","android","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
tests: []
file_path: kotlin/functions/infra/overpass_nearby_pois.kt
created_at: 2026-04-06T16:54:56Z
updated_at: 2026-04-06T16:54:56Z
props: []
emits: []
variant: []
content_hash: 8e0ff08cec35c8f8a63aa235de86ec1eb111272ee275f67b30861341edbe263f
signature:
  fun overpassNearbyPois(lat: Double, lon: Double, radiusM: Int = 500, categories: List<String>? = null): List<POI>

description:
  Consulta la Overpass API (OpenStreetMap) para obtener POIs cercanos a una coordenada. Soporta 16 categorias mapeadas a tags OSM (amenity, tourism, historic, leisure, shop). Sin dependencias externas: solo Android SDK (HttpURLConnection + org.json).

imports:
  ["org.json.JSONObject","org.json.JSONArray","java.net.HttpURLConnection","java.net.URL","java.net.URLEncoder"]

example:
  ```kotlin
  // Restaurantes y museos en un radio de 300 metros
  val pois = overpassNearbyPois(
      lat = 40.4168,
      lon = -3.7038,
      radiusM = 300,
      categories = listOf("restaurant", "museum")
  )
  pois.forEach { poi ->
      println("${poi.name} (${poi.category}) — lat=${poi.lat}, lon=${poi.lon}")
  }

  // Todos los POIs en 500 metros (null = todas las categorias)
  val all = overpassNearbyPois(lat = 48.8566, lon = 2.3522)
  println("Paris: ${all.size} POIs encontrados")
  ```

notes:
  - Usa `java.net.HttpURLConnection` con POST y `data=<query_url_encoded>`.
  - Timeouts: 10 segundos de conexion y lectura. La query QL tambien declara `[timeout:10]`.
  - El tipo `POI` se define en el mismo archivo como `data class`.
  - El mapa `tags` contiene todos los tags OSM del nodo, no solo el de la categoria.
  - Si un nodo no tiene tag `name`, el fallback es la categoria y luego "Unknown".
  - Las categorias wildcard (historic, tourism, shop) usan `node["key"](around:...)` sin valor.
  - Lanza `RuntimeException` en caso de categorias invalidas, error HTTP, o JSON malformado.
  - Compatible con Android SDK >= 19 (API level 19 tiene org.json integrado).
  ---

documentation:
  ## Categorias soportadas

  | Categoria    | Tag OSM              |
  |--------------|----------------------|
  | restaurant   | amenity=restaurant   |
  | cafe         | amenity=cafe         |
  | bar          | amenity=bar          |
  | museum       | tourism=museum       |
  | monument     | historic=monument    |
  | park         | leisure=park         |
  | library      | amenity=library      |
  | theatre      | amenity=theatre      |
  | cinema       | amenity=cinema       |
  | gallery      | tourism=gallery      |
  | historic     | historic (wildcard)  |
  | tourism      | tourism (wildcard)   |
  | shop         | shop (wildcard)      |
  | hotel        | tourism=hotel        |
  | pharmacy     | amenity=pharmacy     |
  | hospital     | amenity=hospital     |

  ## Query Overpass QL generada

  ```
  [timeout:10][out:json];
  (
    node["amenity"="restaurant"](around:500,40.4168,-3.7038);
    node["tourism"="museum"](around:500,40.4168,-3.7038);
  );
  out body;
  ```

code:
  package infra

  import org.json.JSONObject
  import org.json.JSONArray
  import java.net.HttpURLConnection
  import java.net.URL
  import java.net.URLEncoder

  data class POI(
      val id: Long,
      val lat: Double,
      val lon: Double,
      val name: String,
      val category: String,
      val tags: Map<String, String>
  )

  private val CATEGORY_TAG_MAP: Map<String, String> = mapOf(
      "restaurant" to "amenity=restaurant",
      "cafe"       to "amenity=cafe",
      "bar"        to "amenity=bar",
      "museum"     to "tourism=museum",
      "monument"   to "historic=monument",
      "park"       to "leisure=park",
      "library"    to "amenity=library",
      "theatre"    to "amenity=theatre",
      "cinema"     to "amenity=cinema",
      "gallery"    to "tourism=gallery",
      "historic"   to "historic",
      "tourism"    to "tourism",
      "shop"       to "shop",
      "hotel"      to "tourism=hotel",
      "pharmacy"   to "amenity=pharmacy",
      "hospital"   to "amenity=hospital"
  )

  /** Builds the Overpass QL node clause for a single category tag expression. */
  private fun nodeClause(tagExpr: String, radiusM: Int, lat: Double, lon: Double): String {
      return if (tagExpr.contains('=')) {
          val (key, value) = tagExpr.split('=', limit = 2)
          """node["$key"="$value"](around:$radiusM,$lat,$lon);"""
      } else {
          // Wildcard: match any value for the key
          """node["$tagExpr"](around:$radiusM,$lat,$lon);"""
      }
  }

  /**
   * OverpassNearbyPois queries the Overpass API (OpenStreetMap) to retrieve POIs
   * near the given coordinates within the specified radius.
   *
   * Uses only the Android SDK (java.net.HttpURLConnection + org.json) — no external
   * dependencies. Throws RuntimeException on HTTP or parse errors.
   *
   * @param lat       Latitude of the center point (WGS84)
   * @param lon       Longitude of the center point (WGS84)
   * @param radiusM   Search radius in metres (default: 500)
   * @param categories Subset of supported categories to filter by, or null for all
   * @return List of POIs found, may be empty
   */
  fun overpassNearbyPois(
      lat: Double,
      lon: Double,
      radiusM: Int = 500,
      categories: List<String>? = null
  ): List<POI> {
      val resolved: Map<String, String> = if (categories == null) {
          CATEGORY_TAG_MAP
      } else {
          CATEGORY_TAG_MAP.filterKeys { it in categories }
      }

      if (resolved.isEmpty()) {
          throw RuntimeException("No valid categories resolved from: $categories")
      }

      val nodeClauses = resolved.values.joinToString("\n  ") { tagExpr ->
          nodeClause(tagExpr, radiusM, lat, lon)
      }
      val query = "[timeout:10][out:json];\n(\n  $nodeClauses\n);\nout body;"

      val endpoint = "https://overpass-api.de/api/interpreter"
      val encodedData = "data=" + URLEncoder.encode(query, "UTF-8")

      val conn = (URL(endpoint).openConnection() as HttpURLConnection).apply {
          requestMethod = "POST"
          connectTimeout = 10_000
          readTimeout = 10_000
          doOutput = true
          setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
          setRequestProperty("Accept", "application/json")
      }

      try {
          conn.outputStream.use { it.write(encodedData.toByteArray(Charsets.UTF_8)) }

          val code = conn.responseCode
          if (code != 200) {
              val err = conn.errorStream?.bufferedReader()?.readText() ?: ""
              throw RuntimeException("Overpass API returned HTTP $code: $err")
          }

          val body = conn.inputStream.bufferedReader().readText()
          val root = JSONObject(body)
          val elements: JSONArray = root.optJSONArray("elements")
              ?: return emptyList()

          val tagExprByCategory: Map<String, String> = resolved.entries
              .associate { (cat, tagExpr) -> tagExpr to cat }

          val pois = mutableListOf<POI>()
          for (i in 0 until elements.length()) {
              val el = elements.getJSONObject(i)
              if (el.optString("type") != "node") continue

              val id  = el.getLong("id")
              val eLat = el.getDouble("lat")
              val eLon = el.getDouble("lon")
              val tagsObj: JSONObject = el.optJSONObject("tags") ?: JSONObject()

              // Build tags map
              val tagsMap = mutableMapOf<String, String>()
              for (key in tagsObj.keys()) {
                  tagsMap[key] = tagsObj.getString(key)
              }

              // Determine category from the first matching tag expression
              val category = tagExprByCategory.entries.firstOrNull { (tagExpr, _) ->
                  if (tagExpr.contains('=')) {
                      val (k, v) = tagExpr.split('=', limit = 2)
                      tagsMap[k] == v
                  } else {
                      tagsMap.containsKey(tagExpr)
                  }
              }?.value ?: "unknown"

              val name = tagsMap["name"] ?: category.takeIf { it != "unknown" } ?: "Unknown"

              pois.add(POI(id = id, lat = eLat, lon = eLon, name = name, category = category, tags = tagsMap))
          }

          return pois
      } finally {
          conn.disconnect()
      }
  }

params_schema:
  {"params":[{"name":"lat","desc":"Latitud del punto central en WGS84 (ej: 40.4168 para Madrid)"},{"name":"lon","desc":"Longitud del punto central en WGS84 (ej: -3.7038 para Madrid)"},{"name":"radiusM","desc":"Radio de busqueda en metros. Default 500. Valores grandes aumentan el tiempo de respuesta."},{"name":"categories","desc":"Lista de categorias a filtrar: restaurant, cafe, bar, museum, monument, park, library, theatre, cinema, gallery, historic, tourism, shop, hotel, pharmacy, hospital. Null devuelve todas."}],"output":"Lista de POI con id OSM, coordenadas (lat/lon), nombre, categoria y mapa completo de tags OSM. Lanza RuntimeException si la API devuelve error HTTP o la respuesta no es JSON valido."}


id: win_firewall_add_rule_ps_infra
name: win_firewall_add_rule
kind: function
lang: ps
domain: infra
version: 1.0.0
purity: impure
signature: win_firewall_add_rule -Name <string> -Port <int> [-Protocol <string>]
tags: ["firewall","windows","netsh","network","infra","security","port","wsl2"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: powershell/functions/infra/win_firewall_add_rule.ps1
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 14ac1a8b15c658eadf232e5e4833e6d7faaf7d73ada3ec12ff08dcbaf374d3de
description:
  Añade una regla de entrada al firewall de Windows para permitir tráfico en un puerto TCP/UDP. Si ya existe una regla con el mismo nombre, la elimina y la recrea. Requiere privilegios de Administrador.

notes:
  - Requiere ejecutarse como **Administrador** en Windows. Si se llama desde WSL2, usar `powershell.exe` (que corre en Windows) o `pwsh.exe`.
  - Si ya existe una regla con el mismo nombre, la elimina primero y la recrea (idempotente respecto al nombre).
  - La regla creada es `dir=in action=allow` — solo aplica para tráfico entrante.
  - Caso de uso principal: permitir que WSL2 alcance Chrome CDP en el host Windows cuando Chrome escucha en `127.0.0.1:9222`.
  - Retorna exit code 0 si tuvo éxito, 1 si hubo error (falta de privilegios, puerto inválido, fallo de netsh).

documentation:
  ## Uso

  ```powershell
  # Desde PowerShell (como Administrador)
  .\win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222

  # Con protocolo explícito
  .\win_firewall_add_rule.ps1 -Name "MyApp-UDP-5000" -Port 5000 -Protocol UDP
  ```

  ```bash
  # Desde WSL2
  powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
  ```

  ## Parametros

  | Parametro  | Tipo   | Obligatorio | Default | Descripcion                          |
  |------------|--------|-------------|---------|--------------------------------------|
  | `-Name`    | string | si          | —       | Nombre de la regla en el firewall    |
  | `-Port`    | int    | si          | —       | Puerto local (1-65535)               |
  | `-Protocol`| string | no          | TCP     | Protocolo: TCP o UDP                 |

code:
  # win_firewall_add_rule.ps1 - Adds a Windows Firewall inbound rule for a TCP/UDP port.
  # Requires: Administrator privileges
  # Usage: powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222

  param(
      [Parameter(Mandatory = $true)]
      [string]$Name,

      [Parameter(Mandatory = $true)]
      [int]$Port,

      [Parameter(Mandatory = $false)]
      [string]$Protocol = "TCP"
  )

  # Verify administrator privileges
  $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
  if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
      exit 1
  }

  # Validate protocol
  $validProtocols = @("TCP", "UDP")
  if ($validProtocols -notcontains $Protocol.ToUpper()) {
      Write-Error "ERROR: Protocol must be TCP or UDP, got '$Protocol'."
      exit 1
  }

  # Validate port range
  if ($Port -lt 1 -or $Port -gt 65535) {
      Write-Error "ERROR: Port must be between 1 and 65535, got '$Port'."
      exit 1
  }

  # Remove existing rule with the same name if it exists
  $existingRule = netsh advfirewall firewall show rule name="$Name" 2>&1
  if ($LASTEXITCODE -eq 0) {
      Write-Host "Removing existing rule '$Name'..."
      netsh advfirewall firewall delete rule name="$Name" | Out-Null
      if ($LASTEXITCODE -ne 0) {
          Write-Error "ERROR: Failed to remove existing rule '$Name'."
          exit 1
      }
  }

  # Add the new inbound rule
  Write-Host "Adding firewall rule '$Name' for $Protocol port $Port..."
  netsh advfirewall firewall add rule name="$Name" dir=in action=allow protocol=$Protocol localport=$Port

  if ($LASTEXITCODE -ne 0) {
      Write-Error "ERROR: Failed to add firewall rule '$Name'."
      exit 1
  }

  Write-Host "OK: Firewall rule '$Name' added - $Protocol inbound on port $Port."

params_schema:
  {"params":[{"name":"Name","desc":"Nombre de la regla en el firewall"},{"name":"Port","desc":"Puerto local a permitir (1-65535)"},{"name":"Protocol","desc":"Protocolo TCP o UDP (default: TCP)"}],"output":"Void - crea regla de entrada en firewall de Windows, idempotente por nombre"}


id: win_firewall_remove_rule_ps_infra
name: win_firewall_remove_rule
kind: function
lang: ps
domain: infra
version: 1.0.0
purity: impure
signature: win_firewall_remove_rule -Name <string>
tags: ["firewall","windows","netsh","network","infra","security","cleanup","wsl2"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: powershell/functions/infra/win_firewall_remove_rule.ps1
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 98cebf8d81ddb72d3a71bdb556d43ac0da7117b8ac622a86b79d8d4990f2f160
description:
  Elimina una regla del firewall de Windows por nombre. Si la regla no existe, termina con éxito sin hacer nada (idempotente). Requiere privilegios de Administrador.

notes:
  - Requiere ejecutarse como **Administrador** en Windows.
  - Idempotente: si la regla no existe, sale con exit code 0 y mensaje informativo.
  - Complementa `win_firewall_add_rule` para teardown limpio de reglas temporales.
  - Retorna exit code 0 si tuvo éxito (o la regla no existía), 1 si hubo error.

documentation:
  ## Uso

  ```powershell
  # Desde PowerShell (como Administrador)
  .\win_firewall_remove_rule.ps1 -Name "CDP-9222"
  ```

  ```bash
  # Desde WSL2
  powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
  ```

  ## Parametros

  | Parametro | Tipo   | Obligatorio | Descripcion                       |
  |-----------|--------|-------------|-----------------------------------|
  | `-Name`   | string | si          | Nombre exacto de la regla a eliminar |

code:
  # win_firewall_remove_rule.ps1 - Removes a Windows Firewall rule by name.
  # Requires: Administrator privileges
  # Usage: powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"

  param(
      [Parameter(Mandatory = $true)]
      [string]$Name
  )

  # Verify administrator privileges
  $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
  if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
      exit 1
  }

  # Check if the rule exists
  $existingRule = netsh advfirewall firewall show rule name="$Name" 2>&1
  if ($LASTEXITCODE -ne 0) {
      Write-Host "INFO: Firewall rule '$Name' does not exist - nothing to remove."
      exit 0
  }

  # Remove the rule
  Write-Host "Removing firewall rule '$Name'..."
  netsh advfirewall firewall delete rule name="$Name"

  if ($LASTEXITCODE -ne 0) {
      Write-Error "ERROR: Failed to remove firewall rule '$Name'."
      exit 1
  }

  Write-Host "OK: Firewall rule '$Name' removed."

params_schema:
  {"params":[{"name":"Name","desc":"Nombre exacto de la regla a eliminar"}],"output":"Void - elimina regla del firewall de Windows, idempotente (no falla si no existe)"}


id: win_portproxy_add_ps_infra
name: win_portproxy_add
kind: function
lang: ps
domain: infra
version: 1.0.0
purity: impure
tags: ["portproxy","netsh","windows","network","infra","wsl2","cdp","redirect","proxy"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: powershell/functions/infra/win_portproxy_add.ps1
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 080fffec2cd5b207b92b99f1bbd4e02c2088861f5a39f1a03e15c8b6bb2e3321
signature:
  win_portproxy_add -ListenPort <int> [-ConnectPort <int>] [-ListenAddr <string>] [-ConnectAddr <string>]

description:
  Añade una regla de port proxy v4tov4 con netsh para redirigir tráfico desde ListenAddr:ListenPort hacia ConnectAddr:ConnectPort. Si ya existe una regla para el mismo listenaddress:listenport, la elimina y la recrea. Requiere privilegios de Administrador.

notes:
  - Requiere ejecutarse como **Administrador** en Windows.
  - Caso de uso principal: WSL2 no puede alcanzar `127.0.0.1` del host Windows directamente. Esta regla hace que el host escuche en `0.0.0.0:PORT` y reenvíe al loopback donde Chrome (u otro proceso) está escuchando.
  - Idempotente respecto a listenaddress:listenport: si ya existe una regla para esa combinación, la elimina y la recrea.
  - Combinar con `win_firewall_add_rule` para que el firewall también permita el tráfico entrante.
  - Retorna exit code 0 si tuvo éxito, 1 si hubo error.

documentation:
  ## Uso

  ```powershell
  # Caso típico WSL2 → Chrome CDP: redirigir 0.0.0.0:9222 → 127.0.0.1:9222
  .\win_portproxy_add.ps1 -ListenPort 9222

  # Con todos los parámetros explícitos
  .\win_portproxy_add.ps1 -ListenPort 9222 -ConnectPort 9222 -ListenAddr 0.0.0.0 -ConnectAddr 127.0.0.1

  # Redirigir a un puerto diferente
  .\win_portproxy_add.ps1 -ListenPort 8080 -ConnectPort 3000
  ```

  ```bash
  # Desde WSL2 — habilitar acceso a Chrome CDP en el host Windows
  powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
  ```

  ## Parametros

  | Parametro      | Tipo   | Obligatorio | Default       | Descripcion                                      |
  |----------------|--------|-------------|---------------|--------------------------------------------------|
  | `-ListenPort`  | int    | si          | —             | Puerto en el que escucha el proxy (1-65535)      |
  | `-ConnectPort` | int    | no          | =ListenPort   | Puerto de destino al que conectar (1-65535)      |
  | `-ListenAddr`  | string | no          | `0.0.0.0`     | Dirección IP en la que escucha el proxy          |
  | `-ConnectAddr` | string | no          | `127.0.0.1`   | Dirección IP de destino                          |

  ## Flujo completo WSL2 → Chrome CDP

  ```bash
  # 1. Abrir Puerto en firewall
  powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222

  # 2. Redirigir tráfico de red al loopback
  powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222

  # 3. Arrancar Chrome con CDP en Windows
  # chrome.exe --remote-debugging-port=9222 --headless

  # 4. Desde WSL2 conectar al host Windows
  # WSL2_HOST=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}')
  # curl http://$WSL2_HOST:9222/json
  ```

code:
  # win_portproxy_add.ps1 - Adds a netsh portproxy rule (v4tov4) to forward traffic.
  # Requires: Administrator privileges
  # Usage: powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
  #         powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222 -ConnectPort 9222 -ListenAddr 0.0.0.0 -ConnectAddr 127.0.0.1

  param(
      [Parameter(Mandatory = $true)]
      [int]$ListenPort,

      [Parameter(Mandatory = $false)]
      [int]$ConnectPort = 0,  # 0 means use ListenPort

      [Parameter(Mandatory = $false)]
      [string]$ListenAddr = "0.0.0.0",

      [Parameter(Mandatory = $false)]
      [string]$ConnectAddr = "127.0.0.1"
  )

  # Verify administrator privileges
  $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
  if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
      exit 1
  }

  # Default ConnectPort to ListenPort if not specified
  if ($ConnectPort -eq 0) {
      $ConnectPort = $ListenPort
  }

  # Validate ports
  if ($ListenPort -lt 1 -or $ListenPort -gt 65535) {
      Write-Error "ERROR: ListenPort must be between 1 and 65535, got '$ListenPort'."
      exit 1
  }
  if ($ConnectPort -lt 1 -or $ConnectPort -gt 65535) {
      Write-Error "ERROR: ConnectPort must be between 1 and 65535, got '$ConnectPort'."
      exit 1
  }

  # Remove existing portproxy for the same listenaddress:listenport if it exists
  $existing = netsh interface portproxy show v4tov4 2>&1 | Select-String "$ListenAddr\s+$ListenPort"
  if ($existing) {
      Write-Host "Removing existing portproxy for ${ListenAddr}:${ListenPort}..."
      netsh interface portproxy delete v4tov4 listenaddress=$ListenAddr listenport=$ListenPort | Out-Null
      if ($LASTEXITCODE -ne 0) {
          Write-Error "ERROR: Failed to remove existing portproxy for ${ListenAddr}:${ListenPort}."
          exit 1
      }
  }

  # Add the portproxy rule
  Write-Host "Adding portproxy: ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}..."
  netsh interface portproxy add v4tov4 listenaddress=$ListenAddr listenport=$ListenPort connectaddress=$ConnectAddr connectport=$ConnectPort

  if ($LASTEXITCODE -ne 0) {
      Write-Error "ERROR: Failed to add portproxy ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}."
      exit 1
  }

  Write-Host "OK: Portproxy added - ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}."

params_schema:
  {"params":[{"name":"ListenPort","desc":"Puerto en el que escucha el proxy (1-65535)"},{"name":"ConnectPort","desc":"Puerto de destino al que conectar (default: =ListenPort)"},{"name":"ListenAddr","desc":"Dirección IP en la que escucha (default: 0.0.0.0)"},{"name":"ConnectAddr","desc":"Dirección IP de destino (default: 127.0.0.1)"}],"output":"Void - crea regla de port proxy v4tov4 vía netsh, idempotente por ListenAddr:ListenPort"}


id: win_portproxy_remove_ps_infra
name: win_portproxy_remove
kind: function
lang: ps
domain: infra
version: 1.0.0
purity: impure
signature: win_portproxy_remove -ListenPort <int> [-ListenAddr <string>]
tags: ["portproxy","netsh","windows","network","infra","wsl2","cleanup","proxy"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: powershell/functions/infra/win_portproxy_remove.ps1
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 8b17027ae10bb417a9405c13d42ede67f8d0aefad27e2b2edf044c03be0e6332
description:
  Elimina una regla de port proxy v4tov4 de netsh identificada por ListenAddr:ListenPort. Si la regla no existe, termina con éxito sin hacer nada (idempotente). Requiere privilegios de Administrador.

notes:
  - Requiere ejecutarse como **Administrador** en Windows.
  - Idempotente: si la regla no existe, sale con exit code 0 y mensaje informativo.
  - Complementa `win_portproxy_add` para teardown limpio.
  - La regla se identifica por la combinación `listenaddress:listenport` — debe coincidir exactamente con la usada en `win_portproxy_add`.
  - Retorna exit code 0 si tuvo éxito (o la regla no existía), 1 si hubo error.

documentation:
  ## Uso

  ```powershell
  # Eliminar portproxy en puerto 9222 (listenaddr por defecto: 0.0.0.0)
  .\win_portproxy_remove.ps1 -ListenPort 9222

  # Con listenaddr explícito
  .\win_portproxy_remove.ps1 -ListenAddr 0.0.0.0 -ListenPort 9222
  ```

  ```bash
  # Desde WSL2
  powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
  ```

  ## Parametros

  | Parametro     | Tipo   | Obligatorio | Default     | Descripcion                                          |
  |---------------|--------|-------------|-------------|------------------------------------------------------|
  | `-ListenPort` | int    | si          | —           | Puerto de escucha de la regla a eliminar (1-65535)   |
  | `-ListenAddr` | string | no          | `0.0.0.0`   | Dirección IP de escucha de la regla a eliminar       |

  ## Teardown completo WSL2 → Chrome CDP

  ```bash
  # 1. Eliminar portproxy
  powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222

  # 2. Eliminar regla de firewall
  powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
  ```

code:
  # win_portproxy_remove.ps1 - Removes a netsh portproxy v4tov4 rule.
  # Requires: Administrator privileges
  # Usage: powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
  #         powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenAddr 0.0.0.0 -ListenPort 9222

  param(
      [Parameter(Mandatory = $true)]
      [int]$ListenPort,

      [Parameter(Mandatory = $false)]
      [string]$ListenAddr = "0.0.0.0"
  )

  # Verify administrator privileges
  $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
  if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
      exit 1
  }

  # Validate port
  if ($ListenPort -lt 1 -or $ListenPort -gt 65535) {
      Write-Error "ERROR: ListenPort must be between 1 and 65535, got '$ListenPort'."
      exit 1
  }

  # Check if the portproxy rule exists
  $existing = netsh interface portproxy show v4tov4 2>&1 | Select-String "$ListenAddr\s+$ListenPort"
  if (-not $existing) {
      Write-Host "INFO: Portproxy for ${ListenAddr}:${ListenPort} does not exist - nothing to remove."
      exit 0
  }

  # Remove the portproxy rule
  Write-Host "Removing portproxy for ${ListenAddr}:${ListenPort}..."
  netsh interface portproxy delete v4tov4 listenaddress=$ListenAddr listenport=$ListenPort

  if ($LASTEXITCODE -ne 0) {
      Write-Error "ERROR: Failed to remove portproxy for ${ListenAddr}:${ListenPort}."
      exit 1
  }

  Write-Host "OK: Portproxy for ${ListenAddr}:${ListenPort} removed."

params_schema:
  {"params":[{"name":"ListenPort","desc":"Puerto de escucha de la regla a eliminar (1-65535)"},{"name":"ListenAddr","desc":"Dirección IP de escucha (default: 0.0.0.0)"}],"output":"Void - elimina regla de port proxy, idempotente (no falla si no existe)"}


id: all_of_py_core
name: all_of
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def all_of(xs: list, pred: callable) -> bool
description: Retorna True si todos los elementos de la lista cumplen el predicado.
tags: ["list","functional","predicate","all","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```python
  result = all_of([2, 4, 6], lambda n: n % 2 == 0)
  # True
  ```

tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Funcion pura. Retorna True para lista vacia (vacuamente verdadero). Cortocircuita al primer False.
content_hash: c4adf0c65600e1da0e7c05c9e30f64c684ee40e5ba09ff04a2781eb8d510556e
code:
  """Core functional programming utilities — pure functions for list/collection operations."""

  import hashlib
  import re
  from functools import reduce as _reduce
  from typing import Any, Callable, Dict, List, Optional, Tuple


  def filter_list(xs: list, pred: Callable) -> list:
      """Filter list by predicate. Does not mutate the original."""
      return [x for x in xs if pred(x)]


  def map_list(xs: list, fn: Callable) -> list:
      """Map function over list. Does not mutate the original."""
      return [fn(x) for x in xs]


  def reduce_list(xs: list, initial: Any, fn: Callable) -> Any:
      """Reduce list with accumulator. fn(acc, x) -> acc."""
      return _reduce(fn, xs, initial)


  def flat_map(xs: list, fn: Callable) -> list:
      """Map function over list then flatten one level."""
      result = []
      for x in xs:
          result.extend(fn(x))
      return result


  def flatten(xss: list) -> list:
      """Flatten a list of lists one level."""
      result = []
      for xs in xss:
          result.extend(xs)
      return result


  def chunk(xs: list, size: int) -> list:
      """Split list into chunks of given size. Last chunk may be smaller."""
      if size <= 0:
          return []
      return [xs[i : i + size] for i in range(0, len(xs), size)]


  def take(xs: list, n: int) -> list:
      """Take first n elements from list."""
      return xs[:n]


  def drop(xs: list, n: int) -> list:
      """Drop first n elements from list."""
      return xs[n:]


  def unique(xs: list) -> list:
      """Remove duplicates preserving order. Uses identity for hashable elements."""
      seen = set()
      result = []
      for x in xs:
          if x not in seen:
              seen.add(x)
              result.append(x)
      return result


  def group_by(xs: list, key_fn: Callable) -> Dict:
      """Group elements by key function. Returns dict of key -> list."""
      groups: Dict = {}
      for x in xs:
          k = key_fn(x)
          if k not in groups:
              groups[k] = []
          groups[k].append(x)
      return groups


  def partition(xs: list, pred: Callable) -> Tuple[list, list]:
      """Split list into (matches, non_matches) based on predicate."""
      matches = []
      non_matches = []
      for x in xs:
          if pred(x):
              matches.append(x)
          else:
              non_matches.append(x)
      return (matches, non_matches)


  def find(xs: list, pred: Callable) -> Any:
      """Find first element matching predicate. Returns None if not found."""
      for x in xs:
          if pred(x):
              return x
      return None


  def find_index(xs: list, pred: Callable) -> int:
      """Find index of first element matching predicate. Returns -1 if not found."""
      for i, x in enumerate(xs):
          if pred(x):
              return i
      return -1


  def zip_with(xs: list, ys: list, fn: Callable) -> list:
      """Zip two lists with a combining function. Stops at shorter list."""
      return [fn(x, y) for x, y in zip(xs, ys)]


  def all_of(xs: list, pred: Callable) -> bool:
      """Return True if all elements match predicate."""
      return all(pred(x) for x in xs)


  def any_of(xs: list, pred: Callable) -> bool:
      """Return True if any element matches predicate."""
      return any(pred(x) for x in xs)


  def pipe(value: Any, *fns: Callable) -> Any:
      """Pipe a value through a sequence of functions left-to-right."""
      result = value
      for fn in fns:
          result = fn(result)
      return result


  def compose(*fns: Callable) -> Callable:
      """Compose functions right-to-left. compose(f, g)(x) == f(g(x))."""
      def composed(x: Any) -> Any:
          result = x
          for fn in reversed(fns):
              result = fn(result)
          return result
      return composed


  # ── Tree manipulation ────────────────────────────────────────────────────────


  def flatten_tree(structure: Any) -> List[Dict]:
      """Flatten a hierarchical tree (dict with 'nodes') to a list without children."""
      import copy
      if isinstance(structure, dict):
          node = copy.deepcopy(structure)
          node.pop('nodes', None)
          nodes = [node]
          for key in list(structure.keys()):
              if 'nodes' in key:
                  nodes.extend(flatten_tree(structure[key]))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(flatten_tree(item))
          return nodes
      return []


  def tree_to_flat_list(structure: Any) -> List[Dict]:
      """Convert hierarchical tree to flat list preserving DFS order (keeps internal nodes)."""
      if isinstance(structure, dict):
          nodes = [structure]
          if 'nodes' in structure:
              nodes.extend(tree_to_flat_list(structure['nodes']))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(tree_to_flat_list(item))
          return nodes
      return []


  def get_leaf_nodes(structure: Any) -> List[Dict]:
      """Extract only leaf nodes (no children) from a hierarchical tree."""
      import copy
      if isinstance(structure, dict):
          if not structure.get('nodes'):
              node = copy.deepcopy(structure)
              node.pop('nodes', None)
              return [node]
          leaf_nodes = []
          for key in list(structure.keys()):
              if 'nodes' in key:
                  leaf_nodes.extend(get_leaf_nodes(structure[key]))
          return leaf_nodes
      elif isinstance(structure, list):
          leaf_nodes = []
          for item in structure:
              leaf_nodes.extend(get_leaf_nodes(item))
          return leaf_nodes
      return []


  def write_node_ids(data: Any, node_id: int = 0) -> int:
      """Assign sequential zero-padded IDs (0001, 0002...) to all nodes in a tree. Returns next counter."""
      if isinstance(data, dict):
          data['node_id'] = str(node_id).zfill(4)
          node_id += 1
          for key in list(data.keys()):
              if 'nodes' in key:
                  node_id = write_node_ids(data[key], node_id)
      elif isinstance(data, list):
          for item in data:
              node_id = write_node_ids(item, node_id)
      return node_id


  def list_to_tree(data: List[Dict]) -> List[Dict]:
      """Convert flat list with structure codes ('1.2.3') to nested tree."""
      def get_parent_structure(structure):
          if not structure:
              return None
          parts = str(structure).split('.')
          return '.'.join(parts[:-1]) if len(parts) > 1 else None

      nodes = {}
      root_nodes = []

      for item in data:
          structure = item.get('structure')
          node = {
              'title': item.get('title'),
              'start_index': item.get('start_index'),
              'end_index': item.get('end_index'),
              'nodes': []
          }
          nodes[structure] = node
          parent_structure = get_parent_structure(structure)

          if parent_structure and parent_structure in nodes:
              nodes[parent_structure]['nodes'].append(node)
          else:
              root_nodes.append(node)

      def clean_node(node):
          if not node['nodes']:
              del node['nodes']
          else:
              for child in node['nodes']:
                  clean_node(child)
          return node

      return [clean_node(node) for node in root_nodes]


  def remove_tree_fields(data: Any, fields: List[str] = None) -> Any:
      """Recursively remove specified fields from a tree (dict/list)."""
      if fields is None:
          fields = ['text']
      if isinstance(data, dict):
          return {k: remove_tree_fields(v, fields) for k, v in data.items() if k not in fields}
      elif isinstance(data, list):
          return [remove_tree_fields(item, fields) for item in data]
      return data


  def format_tree_structure(structure: Any, order: List[str] = None) -> Any:
      """Reorder fields of each node in a tree according to specified key order."""
      if not order:
          return structure
      if isinstance(structure, dict):
          if 'nodes' in structure:
              structure['nodes'] = format_tree_structure(structure['nodes'], order)
          if not structure.get('nodes'):
              structure.pop('nodes', None)
          return {key: structure[key] for key in order if key in structure}
      elif isinstance(structure, list):
          return [format_tree_structure(item, order) for item in structure]
      return structure


  def create_node_mapping(tree: List[Dict]) -> Dict[str, Dict]:
      """Create flat dict mapping node_id to node for O(1) lookup."""
      mapping = {}
      def _traverse(nodes):
          for node in nodes:
              if node.get('node_id'):
                  mapping[node['node_id']] = node
              if node.get('nodes'):
                  _traverse(node['nodes'])
      _traverse(tree)
      return mapping


  # ── Text / JSON extraction ───────────────────────────────────────────────────


  def extract_json_from_llm(content: str) -> Dict:
      """Extract and parse JSON from LLM responses. Handles ```json blocks, trailing commas, None->null."""
      import json
      try:
          start_idx = content.find("```json")
          if start_idx != -1:
              start_idx += 7
              end_idx = content.rfind("```")
              json_content = content[start_idx:end_idx].strip()
          else:
              json_content = content.strip()

          json_content = json_content.replace('None', 'null')
          json_content = json_content.replace('\n', ' ').replace('\r', ' ')
          json_content = ' '.join(json_content.split())

          return json.loads(json_content)
      except (json.JSONDecodeError, Exception):
          try:
              json_content = json_content.replace(',]', ']').replace(',}', '}')
              return json.loads(json_content)
          except Exception:
              return {}


  def parse_page_range(pages: str) -> List[int]:
      """Parse page range string ('5-7', '3,8', '12') into sorted list of unique ints."""
      result = []
      for part in pages.split(','):
          part = part.strip()
          if '-' in part:
              start, end = int(part.split('-', 1)[0].strip()), int(part.split('-', 1)[1].strip())
              if start > end:
                  raise ValueError(f"Invalid range '{part}': start must be <= end")
              result.extend(range(start, end + 1))
          else:
              result.append(int(part))
      return sorted(set(result))


  # ── Markdown parsing ─────────────────────────────────────────────────────────


  def extract_markdown_headers(markdown_content: str) -> Tuple[List[Dict], List[str]]:
      """Extract all headers (h1-h6) from markdown with line numbers, skipping code blocks."""
      import re
      header_pattern = r'^(#{1,6})\s+(.+)$'
      code_block_pattern = r'^```'
      node_list = []
      lines = markdown_content.split('\n')
      in_code_block = False

      for line_num, line in enumerate(lines, 1):
          stripped_line = line.strip()
          if re.match(code_block_pattern, stripped_line):
              in_code_block = not in_code_block
              continue
          if not stripped_line:
              continue
          if not in_code_block:
              match = re.match(header_pattern, stripped_line)
              if match:
                  level = len(match.group(1))
                  title = match.group(2).strip()
                  node_list.append({'title': title, 'level': level, 'line_num': line_num})

      return node_list, lines


  def build_tree_from_headers(node_list: List[Dict]) -> List[Dict]:
      """Build nested tree from flat list of headers with levels (h1>h2>h3)."""
      if not node_list:
          return []

      stack = []
      root_nodes = []
      node_counter = 1

      for node in node_list:
          current_level = node['level']
          tree_node = {
              'title': node['title'],
              'node_id': str(node_counter).zfill(4),
              'line_num': node['line_num'],
              'nodes': []
          }
          node_counter += 1

          while stack and stack[-1][1] >= current_level:
              stack.pop()

          if not stack:
              root_nodes.append(tree_node)
          else:
              parent_node, _ = stack[-1]
              parent_node['nodes'].append(tree_node)

          stack.append((tree_node, current_level))

      def clean_empty_nodes(nodes):
          for n in nodes:
              if n['nodes']:
                  clean_empty_nodes(n['nodes'])
              else:
                  del n['nodes']
          return nodes

      return clean_empty_nodes(root_nodes)


  # ── Pagination / chunking ────────────────────────────────────────────────────


  def page_list_to_groups(page_contents: List[str], token_lengths: List[int],
                          max_tokens: int = 20000, overlap_pages: int = 1) -> List[str]:
      """Group pages into text chunks respecting token limit with configurable overlap."""
      import math
      num_tokens = sum(token_lengths)

      if num_tokens <= max_tokens:
          return ["".join(page_contents)]

      subsets = []
      current_subset = []
      current_token_count = 0

      expected_parts = math.ceil(num_tokens / max_tokens)
      avg_tokens = math.ceil(((num_tokens / expected_parts) + max_tokens) / 2)

      for i, (page_content, page_tokens) in enumerate(zip(page_contents, token_lengths)):
          if current_token_count + page_tokens > avg_tokens:
              subsets.append(''.join(current_subset))
              overlap_start = max(i - overlap_pages, 0)
              current_subset = list(page_contents[overlap_start:i])
              current_token_count = sum(token_lengths[overlap_start:i])

          current_subset.append(page_content)
          current_token_count += page_tokens

      if current_subset:
          subsets.append(''.join(current_subset))

      return subsets


  def calculate_page_offset(pairs: List[Dict]) -> int:
      """Calculate offset between logical page numbers and physical indices using reference pairs."""
      differences = []
      for pair in pairs:
          try:
              difference = pair['physical_index'] - pair['page']
              differences.append(difference)
          except (KeyError, TypeError):
              continue

      if not differences:
          return 0

      counts: Dict[int, int] = {}
      for diff in differences:
          counts[diff] = counts.get(diff, 0) + 1

      return max(counts.items(), key=lambda x: x[1])[0]


  # ── Text preprocessing ───────────────────────────────────────────────────────


  def preprocess_text(text: str) -> str:
      """Normalize whitespace and newlines in raw text.

      Args:
          text: Raw text to normalize.

      Returns:
          Normalized text with consistent newlines, stripped lines, and no
          excessive blank lines.
      """
      # Normalize line endings: \r\n and \r -> \n
      text = text.replace('\r\n', '\n').replace('\r', '\n')
      # Reduce 3+ consecutive newlines to at most 2
      text = re.sub(r'\n{3,}', '\n\n', text)
      # Strip whitespace from each line
      text = '\n'.join(line.strip() for line in text.split('\n'))
      # Strip globally
      return text.strip()


  def get_text_stats(text: str) -> dict:
      """Compute basic statistics of a text: characters, lines, words.

      Args:
          text: Input text to analyze.

      Returns:
          Dict with keys total_chars (int), total_lines (int), total_words (int).
      """
      return {
          'total_chars': len(text),
          'total_lines': text.count('\n') + 1,
          'total_words': len(text.split()),
      }


  # ── Git URL parsing ──────────────────────────────────────────────────────────

  _DEFAULT_GIT_HOSTS = ["github.com", "gitlab.com"]


  def _sanitize_git_segment(segment: str) -> str:
      """Strip .git suffix then keep only [a-zA-Z0-9_-] chars."""
      if segment.endswith(".git"):
          segment = segment[:-4]
      return re.sub(r"[^a-zA-Z0-9_\-]", "", segment)


  def parse_git_url(url: str, known_hosts: Optional[List[str]] = None) -> Optional[str]:
      """Parse a code-hosting URL and return the 'org/repo' path component.

      Supports HTTPS, HTTP, git://, ssh:// and SSH shorthand (git@host:path).
      Returns None if the URL does not match any known host or is malformed.

      Args:
          url: Repository URL in any supported format.
          known_hosts: List of accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          'org/repo' string or None.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      if url.startswith("git@"):
          # git@github.com:org/repo.git
          rest = url[len("git@"):]
          if ":" not in rest:
              return None
          host, path = rest.split(":", 1)
          if host not in hosts:
              return None
          segments = [s for s in path.split("/") if s]
          if len(segments) < 2:
              return None
          org = _sanitize_git_segment(segments[0])
          repo = _sanitize_git_segment(segments[1])
          if not org or not repo:
              return None
          return f"{org}/{repo}"

      for prefix in ("http://", "https://", "git://", "ssh://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              netloc = parsed.hostname or ""
              if netloc not in hosts:
                  return None
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) < 2:
                  return None
              org = _sanitize_git_segment(segments[0])
              repo = _sanitize_git_segment(segments[1])
              if not org or not repo:
                  return None
              return f"{org}/{repo}"

      return None


  def is_git_repo_url(url: str, known_hosts: Optional[List[str]] = None) -> bool:
      """Return True only if url points to a clonable git repository.

      Accepts org/repo and org/repo/tree/<ref> paths.
      Rejects paths that navigate to sub-resources (issues, blobs, PRs, etc.).

      Args:
          url: URL to verify.
          known_hosts: Accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          True if url is a clonable repository URL.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      # SSH shorthand — always repo-level if host matches
      if url.startswith("git@"):
          rest = url[len("git@"):]
          if ":" not in rest:
              return False
          host, _ = rest.split(":", 1)
          return host in hosts

      # git:// and ssh:// — always repo-level if host matches
      for prefix in ("ssh://", "git://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              return (parsed.hostname or "") in hosts

      # http:// and https:// — must have exactly org/repo or org/repo/tree/<ref>
      for prefix in ("http://", "https://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              if (parsed.hostname or "") not in hosts:
                  return False
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) == 2:
                  return True
              if len(segments) == 4 and segments[2] == "tree":
                  return True
              return False

      return False


  def validate_git_ssh_uri(url: str) -> None:
      """Validate a git SSH URI of the form git@host:path.

      Raises ValueError with a descriptive message if the URI is malformed.

      Args:
          url: URI string to validate.

      Raises:
          ValueError: If the URI does not conform to git SSH format.
      """
      if not url.startswith("git@"):
          raise ValueError(f"git SSH URI must start with 'git@', got: {url!r}")
      rest = url[len("git@"):]
      if ":" not in rest:
          raise ValueError(f"git SSH URI must contain ':', got: {url!r}")
      _, path = rest.split(":", 1)
      if not path:
          raise ValueError(f"git SSH URI must have a non-empty path after ':', got: {url!r}")


  # ---------------------------------------------------------------------------
  # Markdown parsing utilities
  # ---------------------------------------------------------------------------


  def extract_frontmatter(content: str) -> Tuple[str, Optional[Dict]]:
      """Extract YAML frontmatter delimited by '---' from the start of a markdown string.

      Args:
          content: Raw markdown string, optionally starting with YAML frontmatter.

      Returns:
          Tuple of (content_without_frontmatter, frontmatter_dict).
          frontmatter_dict is None when no frontmatter is found.
      """
      pattern = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
      match = pattern.match(content)
      if not match:
          return content, None

      raw = match.group(1)
      remaining = content[match.end():]

      try:
          import yaml  # type: ignore
          data = yaml.safe_load(raw)
          if not isinstance(data, dict):
              data = None
      except Exception:
          # Fallback: simple key: value parser (no yaml dependency)
          data = {}
          for line in raw.splitlines():
              if ':' in line:
                  key, _, value = line.partition(':')
                  data[key.strip()] = value.strip()

      return remaining, data


  def find_headings(content: str) -> List[Tuple[int, int, str, int]]:
      """Find all markdown headings (# to ######), excluding those inside code blocks,
      HTML comments, and indented blocks.

      Args:
          content: Markdown text to search.

      Returns:
          List of (start_pos, end_pos, title, level) for each heading found.
      """
      excluded: List[Tuple[int, int]] = []

      # Code blocks (triple backtick)
      for m in re.finditer(r'```.*?```', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # HTML comments
      for m in re.finditer(r'<!--.*?-->', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # Indented blocks (lines starting with 4 spaces or a tab)
      for m in re.finditer(r'^(    |\t).+$', content, re.MULTILINE):
          excluded.append((m.start(), m.end()))

      def is_excluded(pos: int) -> bool:
          return any(start <= pos < end for start, end in excluded)

      results: List[Tuple[int, int, str, int]] = []
      for m in re.finditer(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE):
          # Skip escaped headings (\#)
          before = content[m.start() - 1] if m.start() > 0 else ''
          if before == '\\':
              continue
          if is_excluded(m.start()):
              continue
          level = len(m.group(1))
          title = m.group(2).strip()
          results.append((m.start(), m.end(), title, level))

      return results


  def estimate_token_count(content: str) -> int:
      """Estimate token count without a tokenizer.

      CJK characters count as ~0.7 tokens each; other non-whitespace characters
      count as ~0.3 tokens each.

      Args:
          content: Text to estimate.

      Returns:
          Estimated integer token count.
      """
      cjk = re.findall(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', content)
      without_cjk = re.sub(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', '', content)
      others = re.findall(r'\S', without_cjk)
      return int(len(cjk) * 0.7 + len(others) * 0.3)


  def smart_split_content(
      content: str,
      max_tokens: int = 1024,
      max_chars: int = 8000,
  ) -> List[str]:
      """Split large content into parts respecting token and character limits.

      Splits by paragraphs (double newline). If a single paragraph exceeds the
      limit it is force-cut into chunks of max_chars.

      Args:
          content: Text to split.
          max_tokens: Maximum estimated tokens per part.
          max_chars: Maximum characters per part.

      Returns:
          List of string parts.
      """
      paragraphs = content.split('\n\n')
      parts: List[str] = []
      current_parts: List[str] = []
      current_tokens = 0
      current_chars = 0

      def flush() -> None:
          if current_parts:
              parts.append('\n\n'.join(current_parts))
              current_parts.clear()

      for para in paragraphs:
          para_tokens = estimate_token_count(para)
          para_chars = len(para)

          # Single paragraph exceeds limits — force-cut it
          if para_tokens > max_tokens or para_chars > max_chars:
              flush()
              current_tokens = 0
              current_chars = 0
              for i in range(0, len(para), max_chars):
                  parts.append(para[i:i + max_chars])
              continue

          # Would exceed limits if added — flush first
          if (current_tokens + para_tokens > max_tokens or
                  current_chars + para_chars > max_chars):
              flush()
              current_tokens = 0
              current_chars = 0

          current_parts.append(para)
          current_tokens += para_tokens
          current_chars += para_chars

      flush()
      return parts if parts else [content]


  def sanitize_for_path(text: str, max_length: int = 50) -> str:
      """Convert text to a safe string for use in file paths.

      Keeps word characters, CJK characters, spaces and hyphens. Replaces spaces
      with underscores. Truncates with a sha256 suffix if the result exceeds
      max_length.

      Args:
          text: Input text to sanitize.
          max_length: Maximum length of the returned string.

      Returns:
          Safe path-friendly string.
      """
      cleaned = re.sub(
          r'[^\w\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af \-]',
          '',
          text,
      )
      cleaned = cleaned.replace(' ', '_').strip('_')

      if not cleaned:
          return 'section'

      if len(cleaned) <= max_length:
          return cleaned

      suffix = '_' + hashlib.sha256(text.encode()).hexdigest()[:8]
      return cleaned[:max_length - len(suffix)] + suffix

params_schema:
  {"params":[{"name":"xs","desc":"lista de elementos a evaluar"},{"name":"pred","desc":"predicado (funcion que retorna bool) para evaluar cada elemento"}],"output":"booleano: True si todos los elementos cumplen el predicado, False en caso contrario"}


id: any_of_py_core
name: any_of
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def any_of(xs: list, pred: callable) -> bool
description: Retorna True si al menos un elemento de la lista cumple el predicado.
tags: ["list","functional","predicate","any","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```python
  result = any_of([1, 3, 5, 4], lambda n: n % 2 == 0)
  # True
  ```

tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Funcion pura. Retorna False para lista vacia. Cortocircuita al primer True.
content_hash: 81eaac976d537b622956a9b2290b9f24101a3a19d0285fa415a2cad2637927e0
code:
  """Core functional programming utilities — pure functions for list/collection operations."""

  import hashlib
  import re
  from functools import reduce as _reduce
  from typing import Any, Callable, Dict, List, Optional, Tuple


  def filter_list(xs: list, pred: Callable) -> list:
      """Filter list by predicate. Does not mutate the original."""
      return [x for x in xs if pred(x)]


  def map_list(xs: list, fn: Callable) -> list:
      """Map function over list. Does not mutate the original."""
      return [fn(x) for x in xs]


  def reduce_list(xs: list, initial: Any, fn: Callable) -> Any:
      """Reduce list with accumulator. fn(acc, x) -> acc."""
      return _reduce(fn, xs, initial)


  def flat_map(xs: list, fn: Callable) -> list:
      """Map function over list then flatten one level."""
      result = []
      for x in xs:
          result.extend(fn(x))
      return result


  def flatten(xss: list) -> list:
      """Flatten a list of lists one level."""
      result = []
      for xs in xss:
          result.extend(xs)
      return result


  def chunk(xs: list, size: int) -> list:
      """Split list into chunks of given size. Last chunk may be smaller."""
      if size <= 0:
          return []
      return [xs[i : i + size] for i in range(0, len(xs), size)]


  def take(xs: list, n: int) -> list:
      """Take first n elements from list."""
      return xs[:n]


  def drop(xs: list, n: int) -> list:
      """Drop first n elements from list."""
      return xs[n:]


  def unique(xs: list) -> list:
      """Remove duplicates preserving order. Uses identity for hashable elements."""
      seen = set()
      result = []
      for x in xs:
          if x not in seen:
              seen.add(x)
              result.append(x)
      return result


  def group_by(xs: list, key_fn: Callable) -> Dict:
      """Group elements by key function. Returns dict of key -> list."""
      groups: Dict = {}
      for x in xs:
          k = key_fn(x)
          if k not in groups:
              groups[k] = []
          groups[k].append(x)
      return groups


  def partition(xs: list, pred: Callable) -> Tuple[list, list]:
      """Split list into (matches, non_matches) based on predicate."""
      matches = []
      non_matches = []
      for x in xs:
          if pred(x):
              matches.append(x)
          else:
              non_matches.append(x)
      return (matches, non_matches)


  def find(xs: list, pred: Callable) -> Any:
      """Find first element matching predicate. Returns None if not found."""
      for x in xs:
          if pred(x):
              return x
      return None


  def find_index(xs: list, pred: Callable) -> int:
      """Find index of first element matching predicate. Returns -1 if not found."""
      for i, x in enumerate(xs):
          if pred(x):
              return i
      return -1


  def zip_with(xs: list, ys: list, fn: Callable) -> list:
      """Zip two lists with a combining function. Stops at shorter list."""
      return [fn(x, y) for x, y in zip(xs, ys)]


  def all_of(xs: list, pred: Callable) -> bool:
      """Return True if all elements match predicate."""
      return all(pred(x) for x in xs)


  def any_of(xs: list, pred: Callable) -> bool:
      """Return True if any element matches predicate."""
      return any(pred(x) for x in xs)


  def pipe(value: Any, *fns: Callable) -> Any:
      """Pipe a value through a sequence of functions left-to-right."""
      result = value
      for fn in fns:
          result = fn(result)
      return result


  def compose(*fns: Callable) -> Callable:
      """Compose functions right-to-left. compose(f, g)(x) == f(g(x))."""
      def composed(x: Any) -> Any:
          result = x
          for fn in reversed(fns):
              result = fn(result)
          return result
      return composed


  # ── Tree manipulation ────────────────────────────────────────────────────────


  def flatten_tree(structure: Any) -> List[Dict]:
      """Flatten a hierarchical tree (dict with 'nodes') to a list without children."""
      import copy
      if isinstance(structure, dict):
          node = copy.deepcopy(structure)
          node.pop('nodes', None)
          nodes = [node]
          for key in list(structure.keys()):
              if 'nodes' in key:
                  nodes.extend(flatten_tree(structure[key]))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(flatten_tree(item))
          return nodes
      return []


  def tree_to_flat_list(structure: Any) -> List[Dict]:
      """Convert hierarchical tree to flat list preserving DFS order (keeps internal nodes)."""
      if isinstance(structure, dict):
          nodes = [structure]
          if 'nodes' in structure:
              nodes.extend(tree_to_flat_list(structure['nodes']))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(tree_to_flat_list(item))
          return nodes
      return []


  def get_leaf_nodes(structure: Any) -> List[Dict]:
      """Extract only leaf nodes (no children) from a hierarchical tree."""
      import copy
      if isinstance(structure, dict):
          if not structure.get('nodes'):
              node = copy.deepcopy(structure)
              node.pop('nodes', None)
              return [node]
          leaf_nodes = []
          for key in list(structure.keys()):
              if 'nodes' in key:
                  leaf_nodes.extend(get_leaf_nodes(structure[key]))
          return leaf_nodes
      elif isinstance(structure, list):
          leaf_nodes = []
          for item in structure:
              leaf_nodes.extend(get_leaf_nodes(item))
          return leaf_nodes
      return []


  def write_node_ids(data: Any, node_id: int = 0) -> int:
      """Assign sequential zero-padded IDs (0001, 0002...) to all nodes in a tree. Returns next counter."""
      if isinstance(data, dict):
          data['node_id'] = str(node_id).zfill(4)
          node_id += 1
          for key in list(data.keys()):
              if 'nodes' in key:
                  node_id = write_node_ids(data[key], node_id)
      elif isinstance(data, list):
          for item in data:
              node_id = write_node_ids(item, node_id)
      return node_id


  def list_to_tree(data: List[Dict]) -> List[Dict]:
      """Convert flat list with structure codes ('1.2.3') to nested tree."""
      def get_parent_structure(structure):
          if not structure:
              return None
          parts = str(structure).split('.')
          return '.'.join(parts[:-1]) if len(parts) > 1 else None

      nodes = {}
      root_nodes = []

      for item in data:
          structure = item.get('structure')
          node = {
              'title': item.get('title'),
              'start_index': item.get('start_index'),
              'end_index': item.get('end_index'),
              'nodes': []
          }
          nodes[structure] = node
          parent_structure = get_parent_structure(structure)

          if parent_structure and parent_structure in nodes:
              nodes[parent_structure]['nodes'].append(node)
          else:
              root_nodes.append(node)

      def clean_node(node):
          if not node['nodes']:
              del node['nodes']
          else:
              for child in node['nodes']:
                  clean_node(child)
          return node

      return [clean_node(node) for node in root_nodes]


  def remove_tree_fields(data: Any, fields: List[str] = None) -> Any:
      """Recursively remove specified fields from a tree (dict/list)."""
      if fields is None:
          fields = ['text']
      if isinstance(data, dict):
          return {k: remove_tree_fields(v, fields) for k, v in data.items() if k not in fields}
      elif isinstance(data, list):
          return [remove_tree_fields(item, fields) for item in data]
      return data


  def format_tree_structure(structure: Any, order: List[str] = None) -> Any:
      """Reorder fields of each node in a tree according to specified key order."""
      if not order:
          return structure
      if isinstance(structure, dict):
          if 'nodes' in structure:
              structure['nodes'] = format_tree_structure(structure['nodes'], order)
          if not structure.get('nodes'):
              structure.pop('nodes', None)
          return {key: structure[key] for key in order if key in structure}
      elif isinstance(structure, list):
          return [format_tree_structure(item, order) for item in structure]
      return structure


  def create_node_mapping(tree: List[Dict]) -> Dict[str, Dict]:
      """Create flat dict mapping node_id to node for O(1) lookup."""
      mapping = {}
      def _traverse(nodes):
          for node in nodes:
              if node.get('node_id'):
                  mapping[node['node_id']] = node
              if node.get('nodes'):
                  _traverse(node['nodes'])
      _traverse(tree)
      return mapping


  # ── Text / JSON extraction ───────────────────────────────────────────────────


  def extract_json_from_llm(content: str) -> Dict:
      """Extract and parse JSON from LLM responses. Handles ```json blocks, trailing commas, None->null."""
      import json
      try:
          start_idx = content.find("```json")
          if start_idx != -1:
              start_idx += 7
              end_idx = content.rfind("```")
              json_content = content[start_idx:end_idx].strip()
          else:
              json_content = content.strip()

          json_content = json_content.replace('None', 'null')
          json_content = json_content.replace('\n', ' ').replace('\r', ' ')
          json_content = ' '.join(json_content.split())

          return json.loads(json_content)
      except (json.JSONDecodeError, Exception):
          try:
              json_content = json_content.replace(',]', ']').replace(',}', '}')
              return json.loads(json_content)
          except Exception:
              return {}


  def parse_page_range(pages: str) -> List[int]:
      """Parse page range string ('5-7', '3,8', '12') into sorted list of unique ints."""
      result = []
      for part in pages.split(','):
          part = part.strip()
          if '-' in part:
              start, end = int(part.split('-', 1)[0].strip()), int(part.split('-', 1)[1].strip())
              if start > end:
                  raise ValueError(f"Invalid range '{part}': start must be <= end")
              result.extend(range(start, end + 1))
          else:
              result.append(int(part))
      return sorted(set(result))


  # ── Markdown parsing ─────────────────────────────────────────────────────────


  def extract_markdown_headers(markdown_content: str) -> Tuple[List[Dict], List[str]]:
      """Extract all headers (h1-h6) from markdown with line numbers, skipping code blocks."""
      import re
      header_pattern = r'^(#{1,6})\s+(.+)$'
      code_block_pattern = r'^```'
      node_list = []
      lines = markdown_content.split('\n')
      in_code_block = False

      for line_num, line in enumerate(lines, 1):
          stripped_line = line.strip()
          if re.match(code_block_pattern, stripped_line):
              in_code_block = not in_code_block
              continue
          if not stripped_line:
              continue
          if not in_code_block:
              match = re.match(header_pattern, stripped_line)
              if match:
                  level = len(match.group(1))
                  title = match.group(2).strip()
                  node_list.append({'title': title, 'level': level, 'line_num': line_num})

      return node_list, lines


  def build_tree_from_headers(node_list: List[Dict]) -> List[Dict]:
      """Build nested tree from flat list of headers with levels (h1>h2>h3)."""
      if not node_list:
          return []

      stack = []
      root_nodes = []
      node_counter = 1

      for node in node_list:
          current_level = node['level']
          tree_node = {
              'title': node['title'],
              'node_id': str(node_counter).zfill(4),
              'line_num': node['line_num'],
              'nodes': []
          }
          node_counter += 1

          while stack and stack[-1][1] >= current_level:
              stack.pop()

          if not stack:
              root_nodes.append(tree_node)
          else:
              parent_node, _ = stack[-1]
              parent_node['nodes'].append(tree_node)

          stack.append((tree_node, current_level))

      def clean_empty_nodes(nodes):
          for n in nodes:
              if n['nodes']:
                  clean_empty_nodes(n['nodes'])
              else:
                  del n['nodes']
          return nodes

      return clean_empty_nodes(root_nodes)


  # ── Pagination / chunking ────────────────────────────────────────────────────


  def page_list_to_groups(page_contents: List[str], token_lengths: List[int],
                          max_tokens: int = 20000, overlap_pages: int = 1) -> List[str]:
      """Group pages into text chunks respecting token limit with configurable overlap."""
      import math
      num_tokens = sum(token_lengths)

      if num_tokens <= max_tokens:
          return ["".join(page_contents)]

      subsets = []
      current_subset = []
      current_token_count = 0

      expected_parts = math.ceil(num_tokens / max_tokens)
      avg_tokens = math.ceil(((num_tokens / expected_parts) + max_tokens) / 2)

      for i, (page_content, page_tokens) in enumerate(zip(page_contents, token_lengths)):
          if current_token_count + page_tokens > avg_tokens:
              subsets.append(''.join(current_subset))
              overlap_start = max(i - overlap_pages, 0)
              current_subset = list(page_contents[overlap_start:i])
              current_token_count = sum(token_lengths[overlap_start:i])

          current_subset.append(page_content)
          current_token_count += page_tokens

      if current_subset:
          subsets.append(''.join(current_subset))

      return subsets


  def calculate_page_offset(pairs: List[Dict]) -> int:
      """Calculate offset between logical page numbers and physical indices using reference pairs."""
      differences = []
      for pair in pairs:
          try:
              difference = pair['physical_index'] - pair['page']
              differences.append(difference)
          except (KeyError, TypeError):
              continue

      if not differences:
          return 0

      counts: Dict[int, int] = {}
      for diff in differences:
          counts[diff] = counts.get(diff, 0) + 1

      return max(counts.items(), key=lambda x: x[1])[0]


  # ── Text preprocessing ───────────────────────────────────────────────────────


  def preprocess_text(text: str) -> str:
      """Normalize whitespace and newlines in raw text.

      Args:
          text: Raw text to normalize.

      Returns:
          Normalized text with consistent newlines, stripped lines, and no
          excessive blank lines.
      """
      # Normalize line endings: \r\n and \r -> \n
      text = text.replace('\r\n', '\n').replace('\r', '\n')
      # Reduce 3+ consecutive newlines to at most 2
      text = re.sub(r'\n{3,}', '\n\n', text)
      # Strip whitespace from each line
      text = '\n'.join(line.strip() for line in text.split('\n'))
      # Strip globally
      return text.strip()


  def get_text_stats(text: str) -> dict:
      """Compute basic statistics of a text: characters, lines, words.

      Args:
          text: Input text to analyze.

      Returns:
          Dict with keys total_chars (int), total_lines (int), total_words (int).
      """
      return {
          'total_chars': len(text),
          'total_lines': text.count('\n') + 1,
          'total_words': len(text.split()),
      }


  # ── Git URL parsing ──────────────────────────────────────────────────────────

  _DEFAULT_GIT_HOSTS = ["github.com", "gitlab.com"]


  def _sanitize_git_segment(segment: str) -> str:
      """Strip .git suffix then keep only [a-zA-Z0-9_-] chars."""
      if segment.endswith(".git"):
          segment = segment[:-4]
      return re.sub(r"[^a-zA-Z0-9_\-]", "", segment)


  def parse_git_url(url: str, known_hosts: Optional[List[str]] = None) -> Optional[str]:
      """Parse a code-hosting URL and return the 'org/repo' path component.

      Supports HTTPS, HTTP, git://, ssh:// and SSH shorthand (git@host:path).
      Returns None if the URL does not match any known host or is malformed.

      Args:
          url: Repository URL in any supported format.
          known_hosts: List of accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          'org/repo' string or None.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      if url.startswith("git@"):
          # git@github.com:org/repo.git
          rest = url[len("git@"):]
          if ":" not in rest:
              return None
          host, path = rest.split(":", 1)
          if host not in hosts:
              return None
          segments = [s for s in path.split("/") if s]
          if len(segments) < 2:
              return None
          org = _sanitize_git_segment(segments[0])
          repo = _sanitize_git_segment(segments[1])
          if not org or not repo:
              return None
          return f"{org}/{repo}"

      for prefix in ("http://", "https://", "git://", "ssh://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              netloc = parsed.hostname or ""
              if netloc not in hosts:
                  return None
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) < 2:
                  return None
              org = _sanitize_git_segment(segments[0])
              repo = _sanitize_git_segment(segments[1])
              if not org or not repo:
                  return None
              return f"{org}/{repo}"

      return None


  def is_git_repo_url(url: str, known_hosts: Optional[List[str]] = None) -> bool:
      """Return True only if url points to a clonable git repository.

      Accepts org/repo and org/repo/tree/<ref> paths.
      Rejects paths that navigate to sub-resources (issues, blobs, PRs, etc.).

      Args:
          url: URL to verify.
          known_hosts: Accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          True if url is a clonable repository URL.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      # SSH shorthand — always repo-level if host matches
      if url.startswith("git@"):
          rest = url[len("git@"):]
          if ":" not in rest:
              return False
          host, _ = rest.split(":", 1)
          return host in hosts

      # git:// and ssh:// — always repo-level if host matches
      for prefix in ("ssh://", "git://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              return (parsed.hostname or "") in hosts

      # http:// and https:// — must have exactly org/repo or org/repo/tree/<ref>
      for prefix in ("http://", "https://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              if (parsed.hostname or "") not in hosts:
                  return False
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) == 2:
                  return True
              if len(segments) == 4 and segments[2] == "tree":
                  return True
              return False

      return False


  def validate_git_ssh_uri(url: str) -> None:
      """Validate a git SSH URI of the form git@host:path.

      Raises ValueError with a descriptive message if the URI is malformed.

      Args:
          url: URI string to validate.

      Raises:
          ValueError: If the URI does not conform to git SSH format.
      """
      if not url.startswith("git@"):
          raise ValueError(f"git SSH URI must start with 'git@', got: {url!r}")
      rest = url[len("git@"):]
      if ":" not in rest:
          raise ValueError(f"git SSH URI must contain ':', got: {url!r}")
      _, path = rest.split(":", 1)
      if not path:
          raise ValueError(f"git SSH URI must have a non-empty path after ':', got: {url!r}")


  # ---------------------------------------------------------------------------
  # Markdown parsing utilities
  # ---------------------------------------------------------------------------


  def extract_frontmatter(content: str) -> Tuple[str, Optional[Dict]]:
      """Extract YAML frontmatter delimited by '---' from the start of a markdown string.

      Args:
          content: Raw markdown string, optionally starting with YAML frontmatter.

      Returns:
          Tuple of (content_without_frontmatter, frontmatter_dict).
          frontmatter_dict is None when no frontmatter is found.
      """
      pattern = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
      match = pattern.match(content)
      if not match:
          return content, None

      raw = match.group(1)
      remaining = content[match.end():]

      try:
          import yaml  # type: ignore
          data = yaml.safe_load(raw)
          if not isinstance(data, dict):
              data = None
      except Exception:
          # Fallback: simple key: value parser (no yaml dependency)
          data = {}
          for line in raw.splitlines():
              if ':' in line:
                  key, _, value = line.partition(':')
                  data[key.strip()] = value.strip()

      return remaining, data


  def find_headings(content: str) -> List[Tuple[int, int, str, int]]:
      """Find all markdown headings (# to ######), excluding those inside code blocks,
      HTML comments, and indented blocks.

      Args:
          content: Markdown text to search.

      Returns:
          List of (start_pos, end_pos, title, level) for each heading found.
      """
      excluded: List[Tuple[int, int]] = []

      # Code blocks (triple backtick)
      for m in re.finditer(r'```.*?```', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # HTML comments
      for m in re.finditer(r'<!--.*?-->', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # Indented blocks (lines starting with 4 spaces or a tab)
      for m in re.finditer(r'^(    |\t).+$', content, re.MULTILINE):
          excluded.append((m.start(), m.end()))

      def is_excluded(pos: int) -> bool:
          return any(start <= pos < end for start, end in excluded)

      results: List[Tuple[int, int, str, int]] = []
      for m in re.finditer(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE):
          # Skip escaped headings (\#)
          before = content[m.start() - 1] if m.start() > 0 else ''
          if before == '\\':
              continue
          if is_excluded(m.start()):
              continue
          level = len(m.group(1))
          title = m.group(2).strip()
          results.append((m.start(), m.end(), title, level))

      return results


  def estimate_token_count(content: str) -> int:
      """Estimate token count without a tokenizer.

      CJK characters count as ~0.7 tokens each; other non-whitespace characters
      count as ~0.3 tokens each.

      Args:
          content: Text to estimate.

      Returns:
          Estimated integer token count.
      """
      cjk = re.findall(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', content)
      without_cjk = re.sub(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', '', content)
      others = re.findall(r'\S', without_cjk)
      return int(len(cjk) * 0.7 + len(others) * 0.3)


  def smart_split_content(
      content: str,
      max_tokens: int = 1024,
      max_chars: int = 8000,
  ) -> List[str]:
      """Split large content into parts respecting token and character limits.

      Splits by paragraphs (double newline). If a single paragraph exceeds the
      limit it is force-cut into chunks of max_chars.

      Args:
          content: Text to split.
          max_tokens: Maximum estimated tokens per part.
          max_chars: Maximum characters per part.

      Returns:
          List of string parts.
      """
      paragraphs = content.split('\n\n')
      parts: List[str] = []
      current_parts: List[str] = []
      current_tokens = 0
      current_chars = 0

      def flush() -> None:
          if current_parts:
              parts.append('\n\n'.join(current_parts))
              current_parts.clear()

      for para in paragraphs:
          para_tokens = estimate_token_count(para)
          para_chars = len(para)

          # Single paragraph exceeds limits — force-cut it
          if para_tokens > max_tokens or para_chars > max_chars:
              flush()
              current_tokens = 0
              current_chars = 0
              for i in range(0, len(para), max_chars):
                  parts.append(para[i:i + max_chars])
              continue

          # Would exceed limits if added — flush first
          if (current_tokens + para_tokens > max_tokens or
                  current_chars + para_chars > max_chars):
              flush()
              current_tokens = 0
              current_chars = 0

          current_parts.append(para)
          current_tokens += para_tokens
          current_chars += para_chars

      flush()
      return parts if parts else [content]


  def sanitize_for_path(text: str, max_length: int = 50) -> str:
      """Convert text to a safe string for use in file paths.

      Keeps word characters, CJK characters, spaces and hyphens. Replaces spaces
      with underscores. Truncates with a sha256 suffix if the result exceeds
      max_length.

      Args:
          text: Input text to sanitize.
          max_length: Maximum length of the returned string.

      Returns:
          Safe path-friendly string.
      """
      cleaned = re.sub(
          r'[^\w\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af \-]',
          '',
          text,
      )
      cleaned = cleaned.replace(' ', '_').strip('_')

      if not cleaned:
          return 'section'

      if len(cleaned) <= max_length:
          return cleaned

      suffix = '_' + hashlib.sha256(text.encode()).hexdigest()[:8]
      return cleaned[:max_length - len(suffix)] + suffix

params_schema:
  {"params":[{"name":"xs","desc":"lista de elementos a evaluar"},{"name":"pred","desc":"predicado (funcion que retorna bool) para evaluar cada elemento"}],"output":"booleano: True si al menos un elemento cumple el predicado, False en caso contrario"}


id: build_guide_prompt_py_core
name: build_guide_prompt
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
tags: ["prompt","llm","guide","location","pois","interests","openai","ollama","context"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/core/build_guide_prompt_test.py
file_path: python/functions/core/build_guide_prompt.py
created_at: 2026-04-06T16:24:08Z
updated_at: 2026-04-06T16:24:08Z
props: []
emits: []
variant: []
content_hash: 4fc88d4a5e779f82b617f911546e7adae6899c918b7ffd0ece7485921aac73f9
signature:
  def build_guide_prompt(location: dict, pois: list[dict], interests: list[dict], user_query: str = "", lang: str = "es") -> list[dict]

description:
  Construye un prompt contextual para el LLM combinando ubicación actual, POIs cercanos rankeados e intereses del usuario. Retorna mensajes en formato OpenAI/Ollama.

example:
  ```python
  messages = build_guide_prompt(
      location={
          "display_name": "Puerta del Sol, Madrid",
          "city": "Madrid",
          "neighbourhood": "Sol",
          "street": "Puerta del Sol",
          "country": "España",
      },
      pois=[
          {
              "name": "Museo del Prado",
              "category": "museum",
              "score": 1.0,
              "matched_interests": ["arte"],
              "lat": 40.41,
              "lon": -3.70,
              "tags": {},
          }
      ],
      interests=[{"name": "arte", "keywords": ["museo"], "weight": 1.0}],
      user_query="¿Qué hay interesante por aquí?",
  )
  # [
  #   {"role": "system", "content": "Eres un guía local experto y amigable. El usuario está caminando por Madrid y le interesan: arte..."},
  #   {"role": "user", "content": "📍 Estoy en: Puerta del Sol, Madrid\nBarrio: Sol, Madrid\n\nLugares cercanos que me pueden interesar:\n- Museo del Prado (museum) — relevante para: arte\n\n¿Qué hay interesante por aquí?"}
  # ]
  ```

tests:
  ["con ubicacion completa pois y query","sin pois solo ubicacion","sin query usa default","max 5 pois","lang aparece en system","location sin neighbourhood"]

notes:
  Función pura — no hace I/O, no muta los inputs, determinista.

  El system message establece el rol del LLM como guía local con conocimiento de los intereses del usuario y el idioma de respuesta. Incluye instrucción de ser conciso (3-4 frases) y conversacional.

  El user message combina:
  - Ubicación completa del usuario (solo campos no vacíos)
  - Lista de hasta 5 POIs con nombre, categoría y los intereses que satisfacen
  - La query del usuario o un fallback genérico

  Los campos de `location` vacíos o ausentes se omiten automáticamente del prompt para no generar ruido. Compatible con el formato de salida de `nominatim_reverse_geocode_py_infra` y `match_pois_to_interests`.

code:
  """Construye un prompt contextual para el LLM combinando ubicación, POIs e intereses del usuario."""


  def build_guide_prompt(
      location: dict,
      pois: list[dict],
      interests: list[dict],
      user_query: str = "",
      lang: str = "es",
  ) -> list[dict]:
      """Construye un prompt contextual para el LLM como guía local.

      Combina la ubicación actual del usuario, POIs cercanos rankeados por relevancia
      e intereses del usuario para generar mensajes en formato OpenAI/Ollama.

      Args:
          location: Dict con info de ubicación de nominatim_reverse_geocode.
                    Campos esperados: display_name, street, neighbourhood, city, country, etc.
          pois: Lista de POIs rankeados con campos: name, category, score,
                matched_interests, lat, lon, tags. Ya filtrados y ordenados por score.
          interests: Lista de intereses del usuario. Cada uno con: name, keywords, weight.
          user_query: Pregunta opcional del usuario. Si está vacía, se usa un default.
          lang: Idioma para la respuesta del LLM (ej: "es", "en", "fr").

      Returns:
          Lista de mensajes en formato OpenAI/Ollama:
          [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]
      """
      # Extraer nombre de intereses para el system prompt
      interest_names = [i.get("name", "") for i in interests if i.get("name")]
      interests_str = ", ".join(interest_names) if interest_names else "lugares de interés general"

      # Extraer ciudad para el system prompt
      city = location.get("city", "") or location.get("town", "") or location.get("village", "") or "la zona"

      # Construir system message
      system_content = (
          f"Eres un guía local experto y amigable. El usuario está caminando por {city} "
          f"y le interesan: {interests_str}.\n"
          "Tu objetivo es informar sobre lo que hay cerca, dar contexto histórico/cultural "
          "relevante a sus intereses, y sugerir qué visitar.\n"
          f"Responde en {lang}. Sé conciso (máximo 3-4 frases) y conversacional, "
          "como si caminaras junto al usuario.\n"
          "Si no hay POIs relevantes, comenta sobre el barrio o zona."
      )

      # Construir user message
      user_parts = []

      # Ubicación
      display_name = location.get("display_name", "")
      if display_name:
          user_parts.append(f"📍 Estoy en: {display_name}")

      neighbourhood = location.get("neighbourhood", "") or location.get("suburb", "")
      city_label = location.get("city", "") or location.get("town", "") or location.get("village", "")
      location_line_parts = [p for p in [neighbourhood, city_label] if p]
      if location_line_parts:
          user_parts.append(f"Barrio: {', '.join(location_line_parts)}")

      # POIs (máximo 5)
      top_pois = pois[:5]
      if top_pois:
          user_parts.append("")
          user_parts.append("Lugares cercanos que me pueden interesar:")
          for poi in top_pois:
              name = poi.get("name", "Sin nombre")
              category = poi.get("category", "")
              matched = poi.get("matched_interests", [])
              matched_str = ", ".join(matched) if matched else ""

              poi_line = f"- {name}"
              if category:
                  poi_line += f" ({category})"
              if matched_str:
                  poi_line += f" — relevante para: {matched_str}"
              user_parts.append(poi_line)

      # Query del usuario
      user_parts.append("")
      if user_query and user_query.strip():
          user_parts.append(user_query.strip())
      else:
          user_parts.append("¿Qué me puedes contar de esta zona?")

      user_content = "\n".join(user_parts)

      return [
          {"role": "system", "content": system_content},
          {"role": "user", "content": user_content},
      ]

params_schema:
  {"params":[{"name":"location","desc":"Dict con info de ubicación de nominatim_reverse_geocode. Campos esperados: display_name, street, neighbourhood, city, country. Los campos vacíos se omiten del prompt."},{"name":"pois","desc":"Lista de POIs rankeados por relevancia (ya filtrados por match_pois_to_interests). Cada POI tiene: name, category, score, matched_interests, lat, lon, tags. Se usan como máximo los primeros 5."},{"name":"interests","desc":"Lista de intereses del usuario. Cada interés tiene: name (str), keywords (list[str]), weight (float). Se usan los names para el system prompt."},{"name":"user_query","desc":"Pregunta opcional del usuario. Si está vacía o no se proporciona, se usa '¿Qué me puedes contar de esta zona?' como default."},{"name":"lang","desc":"Código de idioma para la respuesta del LLM (ej: 'es', 'en', 'fr'). Por defecto 'es'."}],"output":"Lista de dos mensajes en formato OpenAI/Ollama: [{'role': 'system', 'content': '...'}, {'role': 'user', 'content': '...'}]. El system message define el rol de guía local con los intereses y el idioma. El user message incluye la ubicación, los POIs relevantes y la query."}


id: build_tree_from_headers_py_core
name: build_tree_from_headers
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def build_tree_from_headers(node_list: list[dict]) -> list[dict]
description: Construye arbol jerarquico anidado desde lista plana de headers markdown con niveles (h1>h2>h3).
tags: ["tree","markdown","headers","hierarchy"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: bad37891f2f6e23a10e2928913e29b99183b0d26aa563647ae5e9d162926399c
source_repo: https://github.com/VectifyAI/PageIndex
source_license: MIT
source_file: pageindex/page_index_md.py
example:
  ```python
  headers = [
      {"title": "Intro", "level": 1, "line_num": 1},
      {"title": "Background", "level": 2, "line_num": 5},
      {"title": "Details", "level": 3, "line_num": 10},
      {"title": "Methods", "level": 1, "line_num": 20},
  ]
  tree = build_tree_from_headers(headers)
  # [
  #   {"title": "Intro", "node_id": "0001", "nodes": [
  #     {"title": "Background", "node_id": "0002", "nodes": [
  #       {"title": "Details", "node_id": "0003"}
  #     ]}
  #   ]},
  #   {"title": "Methods", "node_id": "0004"}
  # ]
  ```

notes:
  Funcion pura. Asigna node_id secuencial (0001...) automaticamente. Usa stack para resolver jerarquia por nivel de header.

code:
  """Core functional programming utilities — pure functions for list/collection operations."""

  import hashlib
  import re
  from functools import reduce as _reduce
  from typing import Any, Callable, Dict, List, Optional, Tuple


  def filter_list(xs: list, pred: Callable) -> list:
      """Filter list by predicate. Does not mutate the original."""
      return [x for x in xs if pred(x)]


  def map_list(xs: list, fn: Callable) -> list:
      """Map function over list. Does not mutate the original."""
      return [fn(x) for x in xs]


  def reduce_list(xs: list, initial: Any, fn: Callable) -> Any:
      """Reduce list with accumulator. fn(acc, x) -> acc."""
      return _reduce(fn, xs, initial)


  def flat_map(xs: list, fn: Callable) -> list:
      """Map function over list then flatten one level."""
      result = []
      for x in xs:
          result.extend(fn(x))
      return result


  def flatten(xss: list) -> list:
      """Flatten a list of lists one level."""
      result = []
      for xs in xss:
          result.extend(xs)
      return result


  def chunk(xs: list, size: int) -> list:
      """Split list into chunks of given size. Last chunk may be smaller."""
      if size <= 0:
          return []
      return [xs[i : i + size] for i in range(0, len(xs), size)]


  def take(xs: list, n: int) -> list:
      """Take first n elements from list."""
      return xs[:n]


  def drop(xs: list, n: int) -> list:
      """Drop first n elements from list."""
      return xs[n:]


  def unique(xs: list) -> list:
      """Remove duplicates preserving order. Uses identity for hashable elements."""
      seen = set()
      result = []
      for x in xs:
          if x not in seen:
              seen.add(x)
              result.append(x)
      return result


  def group_by(xs: list, key_fn: Callable) -> Dict:
      """Group elements by key function. Returns dict of key -> list."""
      groups: Dict = {}
      for x in xs:
          k = key_fn(x)
          if k not in groups:
              groups[k] = []
          groups[k].append(x)
      return groups


  def partition(xs: list, pred: Callable) -> Tuple[list, list]:
      """Split list into (matches, non_matches) based on predicate."""
      matches = []
      non_matches = []
      for x in xs:
          if pred(x):
              matches.append(x)
          else:
              non_matches.append(x)
      return (matches, non_matches)


  def find(xs: list, pred: Callable) -> Any:
      """Find first element matching predicate. Returns None if not found."""
      for x in xs:
          if pred(x):
              return x
      return None


  def find_index(xs: list, pred: Callable) -> int:
      """Find index of first element matching predicate. Returns -1 if not found."""
      for i, x in enumerate(xs):
          if pred(x):
              return i
      return -1


  def zip_with(xs: list, ys: list, fn: Callable) -> list:
      """Zip two lists with a combining function. Stops at shorter list."""
      return [fn(x, y) for x, y in zip(xs, ys)]


  def all_of(xs: list, pred: Callable) -> bool:
      """Return True if all elements match predicate."""
      return all(pred(x) for x in xs)


  def any_of(xs: list, pred: Callable) -> bool:
      """Return True if any element matches predicate."""
      return any(pred(x) for x in xs)


  def pipe(value: Any, *fns: Callable) -> Any:
      """Pipe a value through a sequence of functions left-to-right."""
      result = value
      for fn in fns:
          result = fn(result)
      return result


  def compose(*fns: Callable) -> Callable:
      """Compose functions right-to-left. compose(f, g)(x) == f(g(x))."""
      def composed(x: Any) -> Any:
          result = x
          for fn in reversed(fns):
              result = fn(result)
          return result
      return composed


  # ── Tree manipulation ────────────────────────────────────────────────────────


  def flatten_tree(structure: Any) -> List[Dict]:
      """Flatten a hierarchical tree (dict with 'nodes') to a list without children."""
      import copy
      if isinstance(structure, dict):
          node = copy.deepcopy(structure)
          node.pop('nodes', None)
          nodes = [node]
          for key in list(structure.keys()):
              if 'nodes' in key:
                  nodes.extend(flatten_tree(structure[key]))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(flatten_tree(item))
          return nodes
      return []


  def tree_to_flat_list(structure: Any) -> List[Dict]:
      """Convert hierarchical tree to flat list preserving DFS order (keeps internal nodes)."""
      if isinstance(structure, dict):
          nodes = [structure]
          if 'nodes' in structure:
              nodes.extend(tree_to_flat_list(structure['nodes']))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(tree_to_flat_list(item))
          return nodes
      return []


  def get_leaf_nodes(structure: Any) -> List[Dict]:
      """Extract only leaf nodes (no children) from a hierarchical tree."""
      import copy
      if isinstance(structure, dict):
          if not structure.get('nodes'):
              node = copy.deepcopy(structure)
              node.pop('nodes', None)
              return [node]
          leaf_nodes = []
          for key in list(structure.keys()):
              if 'nodes' in key:
                  leaf_nodes.extend(get_leaf_nodes(structure[key]))
          return leaf_nodes
      elif isinstance(structure, list):
          leaf_nodes = []
          for item in structure:
              leaf_nodes.extend(get_leaf_nodes(item))
          return leaf_nodes
      return []


  def write_node_ids(data: Any, node_id: int = 0) -> int:
      """Assign sequential zero-padded IDs (0001, 0002...) to all nodes in a tree. Returns next counter."""
      if isinstance(data, dict):
          data['node_id'] = str(node_id).zfill(4)
          node_id += 1
          for key in list(data.keys()):
              if 'nodes' in key:
                  node_id = write_node_ids(data[key], node_id)
      elif isinstance(data, list):
          for item in data:
              node_id = write_node_ids(item, node_id)
      return node_id


  def list_to_tree(data: List[Dict]) -> List[Dict]:
      """Convert flat list with structure codes ('1.2.3') to nested tree."""
      def get_parent_structure(structure):
          if not structure:
              return None
          parts = str(structure).split('.')
          return '.'.join(parts[:-1]) if len(parts) > 1 else None

      nodes = {}
      root_nodes = []

      for item in data:
          structure = item.get('structure')
          node = {
              'title': item.get('title'),
              'start_index': item.get('start_index'),
              'end_index': item.get('end_index'),
              'nodes': []
          }
          nodes[structure] = node
          parent_structure = get_parent_structure(structure)

          if parent_structure and parent_structure in nodes:
              nodes[parent_structure]['nodes'].append(node)
          else:
              root_nodes.append(node)

      def clean_node(node):
          if not node['nodes']:
              del node['nodes']
          else:
              for child in node['nodes']:
                  clean_node(child)
          return node

      return [clean_node(node) for node in root_nodes]


  def remove_tree_fields(data: Any, fields: List[str] = None) -> Any:
      """Recursively remove specified fields from a tree (dict/list)."""
      if fields is None:
          fields = ['text']
      if isinstance(data, dict):
          return {k: remove_tree_fields(v, fields) for k, v in data.items() if k not in fields}
      elif isinstance(data, list):
          return [remove_tree_fields(item, fields) for item in data]
      return data


  def format_tree_structure(structure: Any, order: List[str] = None) -> Any:
      """Reorder fields of each node in a tree according to specified key order."""
      if not order:
          return structure
      if isinstance(structure, dict):
          if 'nodes' in structure:
              structure['nodes'] = format_tree_structure(structure['nodes'], order)
          if not structure.get('nodes'):
              structure.pop('nodes', None)
          return {key: structure[key] for key in order if key in structure}
      elif isinstance(structure, list):
          return [format_tree_structure(item, order) for item in structure]
      return structure


  def create_node_mapping(tree: List[Dict]) -> Dict[str, Dict]:
      """Create flat dict mapping node_id to node for O(1) lookup."""
      mapping = {}
      def _traverse(nodes):
          for node in nodes:
              if node.get('node_id'):
                  mapping[node['node_id']] = node
              if node.get('nodes'):
                  _traverse(node['nodes'])
      _traverse(tree)
      return mapping


  # ── Text / JSON extraction ───────────────────────────────────────────────────


  def extract_json_from_llm(content: str) -> Dict:
      """Extract and parse JSON from LLM responses. Handles ```json blocks, trailing commas, None->null."""
      import json
      try:
          start_idx = content.find("```json")
          if start_idx != -1:
              start_idx += 7
              end_idx = content.rfind("```")
              json_content = content[start_idx:end_idx].strip()
          else:
              json_content = content.strip()

          json_content = json_content.replace('None', 'null')
          json_content = json_content.replace('\n', ' ').replace('\r', ' ')
          json_content = ' '.join(json_content.split())

          return json.loads(json_content)
      except (json.JSONDecodeError, Exception):
          try:
              json_content = json_content.replace(',]', ']').replace(',}', '}')
              return json.loads(json_content)
          except Exception:
              return {}


  def parse_page_range(pages: str) -> List[int]:
      """Parse page range string ('5-7', '3,8', '12') into sorted list of unique ints."""
      result = []
      for part in pages.split(','):
          part = part.strip()
          if '-' in part:
              start, end = int(part.split('-', 1)[0].strip()), int(part.split('-', 1)[1].strip())
              if start > end:
                  raise ValueError(f"Invalid range '{part}': start must be <= end")
              result.extend(range(start, end + 1))
          else:
              result.append(int(part))
      return sorted(set(result))


  # ── Markdown parsing ─────────────────────────────────────────────────────────


  def extract_markdown_headers(markdown_content: str) -> Tuple[List[Dict], List[str]]:
      """Extract all headers (h1-h6) from markdown with line numbers, skipping code blocks."""
      import re
      header_pattern = r'^(#{1,6})\s+(.+)$'
      code_block_pattern = r'^```'
      node_list = []
      lines = markdown_content.split('\n')
      in_code_block = False

      for line_num, line in enumerate(lines, 1):
          stripped_line = line.strip()
          if re.match(code_block_pattern, stripped_line):
              in_code_block = not in_code_block
              continue
          if not stripped_line:
              continue
          if not in_code_block:
              match = re.match(header_pattern, stripped_line)
              if match:
                  level = len(match.group(1))
                  title = match.group(2).strip()
                  node_list.append({'title': title, 'level': level, 'line_num': line_num})

      return node_list, lines


  def build_tree_from_headers(node_list: List[Dict]) -> List[Dict]:
      """Build nested tree from flat list of headers with levels (h1>h2>h3)."""
      if not node_list:
          return []

      stack = []
      root_nodes = []
      node_counter = 1

      for node in node_list:
          current_level = node['level']
          tree_node = {
              'title': node['title'],
              'node_id': str(node_counter).zfill(4),
              'line_num': node['line_num'],
              'nodes': []
          }
          node_counter += 1

          while stack and stack[-1][1] >= current_level:
              stack.pop()

          if not stack:
              root_nodes.append(tree_node)
          else:
              parent_node, _ = stack[-1]
              parent_node['nodes'].append(tree_node)

          stack.append((tree_node, current_level))

      def clean_empty_nodes(nodes):
          for n in nodes:
              if n['nodes']:
                  clean_empty_nodes(n['nodes'])
              else:
                  del n['nodes']
          return nodes

      return clean_empty_nodes(root_nodes)


  # ── Pagination / chunking ────────────────────────────────────────────────────


  def page_list_to_groups(page_contents: List[str], token_lengths: List[int],
                          max_tokens: int = 20000, overlap_pages: int = 1) -> List[str]:
      """Group pages into text chunks respecting token limit with configurable overlap."""
      import math
      num_tokens = sum(token_lengths)

      if num_tokens <= max_tokens:
          return ["".join(page_contents)]

      subsets = []
      current_subset = []
      current_token_count = 0

      expected_parts = math.ceil(num_tokens / max_tokens)
      avg_tokens = math.ceil(((num_tokens / expected_parts) + max_tokens) / 2)

      for i, (page_content, page_tokens) in enumerate(zip(page_contents, token_lengths)):
          if current_token_count + page_tokens > avg_tokens:
              subsets.append(''.join(current_subset))
              overlap_start = max(i - overlap_pages, 0)
              current_subset = list(page_contents[overlap_start:i])
              current_token_count = sum(token_lengths[overlap_start:i])

          current_subset.append(page_content)
          current_token_count += page_tokens

      if current_subset:
          subsets.append(''.join(current_subset))

      return subsets


  def calculate_page_offset(pairs: List[Dict]) -> int:
      """Calculate offset between logical page numbers and physical indices using reference pairs."""
      differences = []
      for pair in pairs:
          try:
              difference = pair['physical_index'] - pair['page']
              differences.append(difference)
          except (KeyError, TypeError):
              continue

      if not differences:
          return 0

      counts: Dict[int, int] = {}
      for diff in differences:
          counts[diff] = counts.get(diff, 0) + 1

      return max(counts.items(), key=lambda x: x[1])[0]


  # ── Text preprocessing ───────────────────────────────────────────────────────


  def preprocess_text(text: str) -> str:
      """Normalize whitespace and newlines in raw text.

      Args:
          text: Raw text to normalize.

      Returns:
          Normalized text with consistent newlines, stripped lines, and no
          excessive blank lines.
      """
      # Normalize line endings: \r\n and \r -> \n
      text = text.replace('\r\n', '\n').replace('\r', '\n')
      # Reduce 3+ consecutive newlines to at most 2
      text = re.sub(r'\n{3,}', '\n\n', text)
      # Strip whitespace from each line
      text = '\n'.join(line.strip() for line in text.split('\n'))
      # Strip globally
      return text.strip()


  def get_text_stats(text: str) -> dict:
      """Compute basic statistics of a text: characters, lines, words.

      Args:
          text: Input text to analyze.

      Returns:
          Dict with keys total_chars (int), total_lines (int), total_words (int).
      """
      return {
          'total_chars': len(text),
          'total_lines': text.count('\n') + 1,
          'total_words': len(text.split()),
      }


  # ── Git URL parsing ──────────────────────────────────────────────────────────

  _DEFAULT_GIT_HOSTS = ["github.com", "gitlab.com"]


  def _sanitize_git_segment(segment: str) -> str:
      """Strip .git suffix then keep only [a-zA-Z0-9_-] chars."""
      if segment.endswith(".git"):
          segment = segment[:-4]
      return re.sub(r"[^a-zA-Z0-9_\-]", "", segment)


  def parse_git_url(url: str, known_hosts: Optional[List[str]] = None) -> Optional[str]:
      """Parse a code-hosting URL and return the 'org/repo' path component.

      Supports HTTPS, HTTP, git://, ssh:// and SSH shorthand (git@host:path).
      Returns None if the URL does not match any known host or is malformed.

      Args:
          url: Repository URL in any supported format.
          known_hosts: List of accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          'org/repo' string or None.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      if url.startswith("git@"):
          # git@github.com:org/repo.git
          rest = url[len("git@"):]
          if ":" not in rest:
              return None
          host, path = rest.split(":", 1)
          if host not in hosts:
              return None
          segments = [s for s in path.split("/") if s]
          if len(segments) < 2:
              return None
          org = _sanitize_git_segment(segments[0])
          repo = _sanitize_git_segment(segments[1])
          if not org or not repo:
              return None
          return f"{org}/{repo}"

      for prefix in ("http://", "https://", "git://", "ssh://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              netloc = parsed.hostname or ""
              if netloc not in hosts:
                  return None
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) < 2:
                  return None
              org = _sanitize_git_segment(segments[0])
              repo = _sanitize_git_segment(segments[1])
              if not org or not repo:
                  return None
              return f"{org}/{repo}"

      return None


  def is_git_repo_url(url: str, known_hosts: Optional[List[str]] = None) -> bool:
      """Return True only if url points to a clonable git repository.

      Accepts org/repo and org/repo/tree/<ref> paths.
      Rejects paths that navigate to sub-resources (issues, blobs, PRs, etc.).

      Args:
          url: URL to verify.
          known_hosts: Accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          True if url is a clonable repository URL.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      # SSH shorthand — always repo-level if host matches
      if url.startswith("git@"):
          rest = url[len("git@"):]
          if ":" not in rest:
              return False
          host, _ = rest.split(":", 1)
          return host in hosts

      # git:// and ssh:// — always repo-level if host matches
      for prefix in ("ssh://", "git://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              return (parsed.hostname or "") in hosts

      # http:// and https:// — must have exactly org/repo or org/repo/tree/<ref>
      for prefix in ("http://", "https://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              if (parsed.hostname or "") not in hosts:
                  return False
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) == 2:
                  return True
              if len(segments) == 4 and segments[2] == "tree":
                  return True
              return False

      return False


  def validate_git_ssh_uri(url: str) -> None:
      """Validate a git SSH URI of the form git@host:path.

      Raises ValueError with a descriptive message if the URI is malformed.

      Args:
          url: URI string to validate.

      Raises:
          ValueError: If the URI does not conform to git SSH format.
      """
      if not url.startswith("git@"):
          raise ValueError(f"git SSH URI must start with 'git@', got: {url!r}")
      rest = url[len("git@"):]
      if ":" not in rest:
          raise ValueError(f"git SSH URI must contain ':', got: {url!r}")
      _, path = rest.split(":", 1)
      if not path:
          raise ValueError(f"git SSH URI must have a non-empty path after ':', got: {url!r}")


  # ---------------------------------------------------------------------------
  # Markdown parsing utilities
  # ---------------------------------------------------------------------------


  def extract_frontmatter(content: str) -> Tuple[str, Optional[Dict]]:
      """Extract YAML frontmatter delimited by '---' from the start of a markdown string.

      Args:
          content: Raw markdown string, optionally starting with YAML frontmatter.

      Returns:
          Tuple of (content_without_frontmatter, frontmatter_dict).
          frontmatter_dict is None when no frontmatter is found.
      """
      pattern = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
      match = pattern.match(content)
      if not match:
          return content, None

      raw = match.group(1)
      remaining = content[match.end():]

      try:
          import yaml  # type: ignore
          data = yaml.safe_load(raw)
          if not isinstance(data, dict):
              data = None
      except Exception:
          # Fallback: simple key: value parser (no yaml dependency)
          data = {}
          for line in raw.splitlines():
              if ':' in line:
                  key, _, value = line.partition(':')
                  data[key.strip()] = value.strip()

      return remaining, data


  def find_headings(content: str) -> List[Tuple[int, int, str, int]]:
      """Find all markdown headings (# to ######), excluding those inside code blocks,
      HTML comments, and indented blocks.

      Args:
          content: Markdown text to search.

      Returns:
          List of (start_pos, end_pos, title, level) for each heading found.
      """
      excluded: List[Tuple[int, int]] = []

      # Code blocks (triple backtick)
      for m in re.finditer(r'```.*?```', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # HTML comments
      for m in re.finditer(r'<!--.*?-->', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # Indented blocks (lines starting with 4 spaces or a tab)
      for m in re.finditer(r'^(    |\t).+$', content, re.MULTILINE):
          excluded.append((m.start(), m.end()))

      def is_excluded(pos: int) -> bool:
          return any(start <= pos < end for start, end in excluded)

      results: List[Tuple[int, int, str, int]] = []
      for m in re.finditer(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE):
          # Skip escaped headings (\#)
          before = content[m.start() - 1] if m.start() > 0 else ''
          if before == '\\':
              continue
          if is_excluded(m.start()):
              continue
          level = len(m.group(1))
          title = m.group(2).strip()
          results.append((m.start(), m.end(), title, level))

      return results


  def estimate_token_count(content: str) -> int:
      """Estimate token count without a tokenizer.

      CJK characters count as ~0.7 tokens each; other non-whitespace characters
      count as ~0.3 tokens each.

      Args:
          content: Text to estimate.

      Returns:
          Estimated integer token count.
      """
      cjk = re.findall(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', content)
      without_cjk = re.sub(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', '', content)
      others = re.findall(r'\S', without_cjk)
      return int(len(cjk) * 0.7 + len(others) * 0.3)


  def smart_split_content(
      content: str,
      max_tokens: int = 1024,
      max_chars: int = 8000,
  ) -> List[str]:
      """Split large content into parts respecting token and character limits.

      Splits by paragraphs (double newline). If a single paragraph exceeds the
      limit it is force-cut into chunks of max_chars.

      Args:
          content: Text to split.
          max_tokens: Maximum estimated tokens per part.
          max_chars: Maximum characters per part.

      Returns:
          List of string parts.
      """
      paragraphs = content.split('\n\n')
      parts: List[str] = []
      current_parts: List[str] = []
      current_tokens = 0
      current_chars = 0

      def flush() -> None:
          if current_parts:
              parts.append('\n\n'.join(current_parts))
              current_parts.clear()

      for para in paragraphs:
          para_tokens = estimate_token_count(para)
          para_chars = len(para)

          # Single paragraph exceeds limits — force-cut it
          if para_tokens > max_tokens or para_chars > max_chars:
              flush()
              current_tokens = 0
              current_chars = 0
              for i in range(0, len(para), max_chars):
                  parts.append(para[i:i + max_chars])
              continue

          # Would exceed limits if added — flush first
          if (current_tokens + para_tokens > max_tokens or
                  current_chars + para_chars > max_chars):
              flush()
              current_tokens = 0
              current_chars = 0

          current_parts.append(para)
          current_tokens += para_tokens
          current_chars += para_chars

      flush()
      return parts if parts else [content]


  def sanitize_for_path(text: str, max_length: int = 50) -> str:
      """Convert text to a safe string for use in file paths.

      Keeps word characters, CJK characters, spaces and hyphens. Replaces spaces
      with underscores. Truncates with a sha256 suffix if the result exceeds
      max_length.

      Args:
          text: Input text to sanitize.
          max_length: Maximum length of the returned string.

      Returns:
          Safe path-friendly string.
      """
      cleaned = re.sub(
          r'[^\w\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af \-]',
          '',
          text,
      )
      cleaned = cleaned.replace(' ', '_').strip('_')

      if not cleaned:
          return 'section'

      if len(cleaned) <= max_length:
          return cleaned

      suffix = '_' + hashlib.sha256(text.encode()).hexdigest()[:8]
      return cleaned[:max_length - len(suffix)] + suffix

params_schema:
  {"params":[{"name":"node_list","desc":"lista plana de dicts con campos title, level y opcionalmente line_num"}],"output":"lista de dicts que forman arbol jerarquico anidado segun niveles"}


id: cache_decorator_py_core
name: cache_decorator
kind: function
lang: py
domain: core
version: 1.0.0
purity: impure
signature: def cache_decorator(store: Any, ttl: float = 0, key_fn: callable | None = None)
tags: ["cache","decorator","memoize","persistence","async","functional"]
uses_functions: ["cache_to_sqlite_py_infra","cache_to_file_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["asyncio","functools","hashlib","json"]
test_file_path: python/functions/core/cache_decorator_test.py
file_path: python/functions/core/cache_decorator.py
created_at: 2026-04-05T14:43:57Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: e3242f52e298ecb0650ab9a4ff464d2d94623051fb0ea3a3c693061e5fa57ac8
description:
  Decorator que cachea el resultado de una funcion en cualquier store persistente compatible (CacheStore o FileCache). La key se genera hasheando (func.__name__, args, sorted(kwargs)) con SHA-256. Soporta funciones sincronas y asincronas.

example:
  ```python
  from infra.cache_to_sqlite import cache_to_sqlite
  from core.cache_decorator import cache_decorator

  store = cache_to_sqlite("cache.db", namespace="llm")

  @cache_decorator(store, ttl=3600)
  def call_llm(prompt: str) -> str:
      # llamada costosa a LLM
      return client.complete(prompt)

  result = call_llm("explain X")  # primera vez: llama LLM
  result = call_llm("explain X")  # segunda vez: desde cache

  # Con key_fn custom
  @cache_decorator(store, ttl=600, key_fn=lambda fn, args, kw: args[0])
  def fetch_user(user_id: str) -> dict:
      return api.get_user(user_id)

  # Con async
  @cache_decorator(store, ttl=3600)
  async def async_call(prompt: str) -> str:
      return await async_client.complete(prompt)
  ```

tests:
  ["Funcion llamada una vez, segunda vez desde cache","TTL expirado → llama de nuevo","key_fn custom","Argumentos distintos → keys distintas","Funciona con async"]

notes:
  El store debe implementar `get(key: str) -> Any | None` y `set(key: str, value: Any, ttl: float) -> None`. Detecta automaticamente funciones asincronas con `asyncio.iscoroutinefunction`. La key por defecto usa `json.dumps(..., default=str)` para serializar argumentos no serializables. Si `store.get()` retorna `None`, siempre se ejecuta la funcion (no distingue entre "no en cache" y "valor None almacenado"); para valores que pueden ser None usar `get_or_set` directamente.

code:
  """Decorator que cachea el resultado de una funcion en un store persistente."""

  import asyncio
  import functools
  import hashlib
  import json
  from typing import Any, Callable


  def _default_key(func: Callable, args: tuple, kwargs: dict) -> str:
      """Genera una cache key a partir del nombre de funcion y sus argumentos."""
      payload = json.dumps((func.__name__, args, sorted(kwargs.items())), default=str)
      return hashlib.sha256(payload.encode("utf-8")).hexdigest()


  def cache_decorator(store: Any, ttl: float = 0, key_fn: Callable | None = None):
      """Retorna un decorator que cachea resultados en un store persistente.

      Args:
          store: Cualquier objeto con metodos get(key) y set(key, value, ttl).
                 Compatible con CacheStore (cache_to_sqlite) y FileCache (cache_to_file).
          ttl: Tiempo de vida en segundos. 0 = sin expiracion.
          key_fn: Funcion opcional para generar la key. Recibe (func, args, kwargs).
                  Si es None, se usa SHA-256 de (func.__name__, args, sorted(kwargs)).

      Returns:
          Decorator aplicable a funciones sincronas o asincronas.

      Example::

          store = cache_to_sqlite("cache.db")

          @cache_decorator(store, ttl=3600)
          def call_llm(prompt: str) -> str:
              ...  # llamada costosa

          result = call_llm("explain X")  # primera vez: ejecuta la funcion
          result = call_llm("explain X")  # segunda vez: desde cache
      """

      def decorator(func: Callable) -> Callable:
          if asyncio.iscoroutinefunction(func):
              @functools.wraps(func)
              async def async_wrapper(*args, **kwargs):
                  make_key = key_fn or _default_key
                  key = make_key(func, args, kwargs)
                  cached = store.get(key)
                  if cached is not None:
                      return cached
                  result = await func(*args, **kwargs)
                  store.set(key, result, ttl)
                  return result
              return async_wrapper
          else:
              @functools.wraps(func)
              def sync_wrapper(*args, **kwargs):
                  make_key = key_fn or _default_key
                  key = make_key(func, args, kwargs)
                  cached = store.get(key)
                  if cached is not None:
                      return cached
                  result = func(*args, **kwargs)
                  store.set(key, result, ttl)
                  return result
              return sync_wrapper

      return decorator

params_schema:
  {"params":[{"name":"store","desc":"store persistente compatible (CacheStore o FileCache) que implementa get/set"},{"name":"ttl","desc":"tiempo de vida del cache en segundos (0 significa infinito)"},{"name":"key_fn","desc":"funcion personalizada para generar clave de cache, recibe (func_name, args, kwargs)"}],"output":"decorator que cachea resultados de la funcion decorada"}


id: calculate_media_strategy_py_core
name: calculate_media_strategy
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: calculate_media_strategy(image_count: int, line_count: int) -> str
tags: ["media","strategy","document","vision","vlm","images","classification"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/core/calculate_media_strategy_test.py
file_path: python/functions/core/calculate_media_strategy.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 31a5107b56d4f03059c9011c13953059f999437a56f5299661a83cf64046e876
description:
  Determina la estrategia optima de procesamiento de medios para un documento basado en la proporcion de imagenes vs texto. Retorna full_page_vlm, extract o text_only.

example:
  ```python
  calculate_media_strategy(0, 50)    # "text_only"
  calculate_media_strategy(2, 100)   # "extract"  (ratio 0.02, pocas imagenes)
  calculate_media_strategy(10, 20)   # "full_page_vlm"  (ratio 0.5 > 0.3)
  calculate_media_strategy(5, 100)   # "full_page_vlm"  (>= 5 imagenes)
  calculate_media_strategy(3, 0)     # "text_only"  (sin texto, sin contexto)
  ```

tests:
  ["0 imagenes text_only","2 imagenes 100 lineas extract","10 imagenes 20 lineas full_page_vlm","5 imagenes 100 lineas full_page_vlm","0 lineas division por cero evitada"]

notes:
  Logica de clasificacion en tres niveles:

  1. `full_page_vlm` — documento dominado por imagenes: ratio imagen/linea > 0.3 o al menos 5 imagenes. Se usa un vision-language model sobre la pagina completa.
  2. `extract` — pocas imagenes en documento con texto: extraer y procesar imagenes individualmente.
  3. `text_only` — sin imagenes o sin lineas de texto: procesar solo el texto.

  El guard `line_count > 0` evita la division por cero y trata documentos sin lineas como `text_only` independientemente del conteo de imagenes, ya que sin texto no hay contexto suficiente para clasificar como `extract`.

  Funcion pura, sin dependencias externas. Reimplementada conceptualmente a partir de la logica de clasificacion de medios de OpenViking (AGPL-3.0).

code:
  """Determina la estrategia optima de procesamiento de medios para un documento."""


  def calculate_media_strategy(image_count: int, line_count: int) -> str:
      """Determina la estrategia optima de procesamiento de medios.

      Clasifica un documento en una de tres estrategias basandose en la
      proporcion de imagenes respecto al texto:
      - full_page_vlm: documento dominado por imagenes, usar vision-language model
      - extract: pocas imagenes, extraer y procesar individualmente
      - text_only: sin imagenes, solo texto

      Args:
          image_count: numero de imagenes en el documento.
          line_count: numero de lineas de texto en el documento.

      Returns:
          "full_page_vlm", "extract" o "text_only".
      """
      if line_count > 0 and (image_count / line_count > 0.3 or image_count >= 5):
          return "full_page_vlm"
      if line_count > 0 and image_count > 0:
          return "extract"
      return "text_only"

params_schema:
  {"params":[{"name":"image_count","desc":"numero total de imagenes en el documento"},{"name":"line_count","desc":"numero total de lineas de texto en el documento"}],"output":"string con estrategia optima: 'full_page_vlm', 'extract' o 'text_only'"}


id: calculate_page_offset_py_core
name: calculate_page_offset
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def calculate_page_offset(pairs: list[dict]) -> int
tags: ["pagination","offset","calculation"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 26657b069c7a9c810fd4b978375eae3d88fccb7eecc6d1fda9a285cf7144c8d5
source_repo: https://github.com/VectifyAI/PageIndex
source_license: MIT
source_file: pageindex/page_index.py
description:
  Calcula offset entre numeros de pagina logicos y fisicos usando pares de referencia (moda de diferencias).

example:
  ```python
  pairs = [
      {"page": 1, "physical_index": 5},
      {"page": 2, "physical_index": 6},
      {"page": 10, "physical_index": 14},
  ]
  calculate_page_offset(pairs)
  # 4 (la moda de las diferencias physical_index - page)
  ```

notes:
  Funcion pura. Cada par necesita campos 'page' (numero logico) y 'physical_index' (indice fisico). Retorna la diferencia mas frecuente (moda). Retorna 0 si no hay pares validos.

code:
[cell repeats an earlier one in this file: 25395 characters]

params_schema:
  {"params":[{"name":"pairs","desc":"lista de dicts con campos 'page' (numero logico) y 'physical_index' (indice fisico)"}],"output":"entero con el offset (diferencia moda entre physical_index - page)"}


id: call_batch_with_retry_py_core
name: call_batch_with_retry
kind: function
lang: py
domain: core
version: 1.0.0
purity: impure
tags: ["retry","batch","backoff","resilience","error-handling","core"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["time","random","typing.Callable","typing.TypeVar"]
test_file_path: python/functions/core/call_batch_with_retry_test.py
file_path: python/functions/core/call_batch_with_retry.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 0ce4898f428d0b7a2f9c87ff0edb4ae7b83cb8025e736a94789b0d5a04c4392b
signature:
  def call_batch_with_retry(items: list[T], process_func: Callable[[T], R], max_retries: int = 3, initial_delay: float = 1.0, max_delay: float = 30.0, backoff_factor: float = 2.0, exceptions: tuple[type[Exception], ...] = (Exception,), continue_on_failure: bool = True) -> tuple[list[R], list[dict]]

description:
  Procesa una lista de items con retry individual por item y exponential backoff. Los fallos individuales no bloquean el resto del batch. Retorna (results, failures) donde failures contiene index, item y error de cada item que agoto sus reintentos.

example:
  ```python
  results, failures = call_batch_with_retry(
      items=["url1", "url2", "url3"],
      process_func=fetch_url,
      max_retries=3,
      initial_delay=1.0,
      max_delay=30.0,
      backoff_factor=2.0,
      exceptions=(ConnectionError, TimeoutError),
      continue_on_failure=True,
  )

  for r in results:
      print("OK:", r)

  for f in failures:
      print(f"FAIL index={f['index']} item={f['item']} error={f['error']}")
  ```

tests:
  ["todos los items exito","item falla permanentemente, continue True","item falla, abort continue False","item falla luego exito retry funciona","failures contiene index correcto"]

notes:
  Diferencia con `retry_sync_py_core`: ese reintenta una sola llamada. Este maneja listas completas donde cada item se reintenta independientemente — los fallos individuales quedan registrados en `failures` sin interrumpir el procesamiento del batch (cuando `continue_on_failure=True`).

  El backoff usa la formula `min(initial_delay * backoff_factor^attempt, max_delay)` con jitter de hasta el 10% del delay calculado para evitar thundering herd. El primer intento es siempre inmediato — el delay se aplica antes del primer retry (attempt=0).

  Cuando `continue_on_failure=False`, el primer item que agota sus reintentos re-lanza la excepcion inmediatamente, abortando el batch.

code:
  """Process a batch of items with per-item exponential backoff retry."""

  import time
  import random
  from typing import Callable, TypeVar

  T = TypeVar("T")
  R = TypeVar("R")


  def call_batch_with_retry(
      items: list,
      process_func: Callable,
      max_retries: int = 3,
      initial_delay: float = 1.0,
      max_delay: float = 30.0,
      backoff_factor: float = 2.0,
      exceptions: tuple = (Exception,),
      continue_on_failure: bool = True,
  ) -> tuple:
      """Process a list of items with independent per-item retry and exponential backoff.

      Each item is processed by process_func. If it raises one of the specified
      exceptions, it is retried up to max_retries times with exponential backoff.
      If all retries are exhausted, the item is recorded as a failure.

      Args:
          items: List of items to process.
          process_func: Callable that takes a single item and returns a result.
          max_retries: Maximum number of retry attempts per item after first failure.
          initial_delay: Initial delay in seconds before the first retry.
          max_delay: Maximum delay cap in seconds between retries.
          backoff_factor: Multiplier applied to delay on each successive retry.
          exceptions: Tuple of exception types to catch and retry on.
          continue_on_failure: If True, continue processing remaining items when an
              item exhausts all retries. If False, re-raise the exception immediately.

      Returns:
          A tuple (results, failures) where:
          - results is a list of successful return values from process_func.
          - failures is a list of dicts with keys "index", "item", and "error"
            for each item that failed after all retries.

      Raises:
          Exception: The last exception for a failed item when continue_on_failure
              is False.
      """
      results = []
      failures = []

      for index, item in enumerate(items):
          last_exc = None
          succeeded = False

          for attempt in range(max_retries + 1):
              try:
                  result = process_func(item)
                  results.append(result)
                  succeeded = True
                  break
              except exceptions as exc:
                  last_exc = exc
                  if attempt < max_retries:
                      delay = min(
                          initial_delay * (backoff_factor ** attempt),
                          max_delay,
                      )
                      # Add small jitter (up to 10% of delay) to avoid thundering herd
                      delay += random.uniform(0, delay * 0.1)
                      time.sleep(delay)

          if not succeeded:
              if not continue_on_failure:
                  raise last_exc
              failures.append({
                  "index": index,
                  "item": item,
                  "error": str(last_exc),
              })

      return results, failures

params_schema:
  {"params":[{"name":"items","desc":"lista de items a procesar"},{"name":"process_func","desc":"funcion que procesa un item individual"},{"name":"max_retries","desc":"numero maximo de reintentos por item"},{"name":"initial_delay","desc":"delay inicial en segundos para exponential backoff"},{"name":"max_delay","desc":"delay maximo en segundos para el backoff"},{"name":"backoff_factor","desc":"factor de multiplicacion para el backoff exponencial"},{"name":"exceptions","desc":"tupla de tipos de excepcion que desencadenan reintentos"},{"name":"continue_on_failure","desc":"si False, aborta el batch en el primer error; si True continua"}],"output":"tupla (results, failures) donde results es lista de valores y failures es lista de dicts con index/item/error"}


id: chunk_py_core
name: chunk
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def chunk(xs: list, size: int) -> list
description: Divide una lista en sublistas de tamanio fijo. El ultimo chunk puede ser menor.
tags: ["list","functional","chunk","partition","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```python
  result = chunk([1, 2, 3, 4, 5], 2)
  # [[1, 2], [3, 4], [5]]
  ```

tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Funcion pura. Si size <= 0 retorna lista vacia.
content_hash: 6d9e89836a7b834ab3e07190ca55ee22912a2af04a3575ff40b990f3e7bbd757
code:
[cell repeats an earlier one in this file: 25395 characters]

params_schema:
  {"params":[{"name":"xs","desc":"lista a dividir"},{"name":"size","desc":"tamanio de cada sublista (chunk)"}],"output":"lista de sublistas, cada una con maximo 'size' elementos"}


id: circuit_breaker_py_core
name: circuit_breaker
kind: function
lang: py
domain: core
version: 1.0.0
purity: impure
tags: ["circuit-breaker","resilience","api","retry","error-handling","thread-safe"]
uses_functions: ["classify_api_error_py_core"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["threading","time","enum"]
test_file_path: python/functions/core/circuit_breaker_test.py
file_path: python/functions/core/circuit_breaker.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5ace17a12c63da75881b967c68c31c5bcb600111ad6489a4de46ea8550abe128
signature:
  class CircuitBreaker:
    def __init__(self, failure_threshold: int = 5, reset_timeout: float = 300.0): ...
    def check(self) -> None: ...
    def record_success(self) -> None: ...
    def record_failure(self, error: Exception) -> None: ...
    @property
    def retry_after(self) -> float: ...

description:
  Patron circuit breaker thread-safe para proteger llamadas a APIs externas. Tres estados: CLOSED (normal), OPEN (bloqueando), HALF_OPEN (permitiendo 1 request de prueba). Integra con classify_api_error para distinguir errores permanentes de transitorios.

example:
  ```python
  from circuit_breaker import CircuitBreaker, CircuitBreakerOpen

  cb = CircuitBreaker(failure_threshold=3, reset_timeout=60.0)

  def call_api() -> dict:
      cb.check()  # raises CircuitBreakerOpen if circuit is open
      try:
          result = requests.get("https://api.example.com/data").json()
          cb.record_success()
          return result
      except Exception as exc:
          cb.record_failure(exc)
          raise

  # After 3 consecutive failures the circuit opens:
  # CircuitBreakerOpen: Circuit breaker is open. Retry after 30.0s
  try:
      cb.check()
  except CircuitBreakerOpen as e:
      print(f"Circuit open, retry in {e.retry_after}s")

  # retry_after property (capped at 30s):
  print(cb.retry_after)  # e.g. 28.4
  ```

tests:
  ["Transicion CLOSED → OPEN despues de N fallos","Transicion OPEN → HALF_OPEN despues de timeout","Transicion HALF_OPEN → CLOSED en exito","Transicion HALF_OPEN → OPEN en fallo","Error permanente abre inmediatamente","Thread safety (concurrencia)","retry_after retorna 0 cuando no esta OPEN"]

notes:
  - **CLOSED**: Requests pasan normalmente. Tras `failure_threshold` fallos consecutivos transiciona a OPEN.
  - **OPEN**: Requests bloqueados con `CircuitBreakerOpen`. Tras `reset_timeout` segundos transiciona a HALF_OPEN.
  - **HALF_OPEN**: Permite 1 request de prueba. Exito → CLOSED. Fallo → OPEN.
  - Errores permanentes (401, 403) abren el circuito inmediatamente sin esperar al umbral.
  - `retry_after` devuelve 0.0 cuando el estado no es OPEN; en OPEN devuelve el tiempo restante, cap 30s.
  - Thread-safe via `threading.Lock` protegiendo todo el estado interno.
  - La dependencia en `classify_api_error` es opcional: si no se puede importar, hay fallback de texto.

code:
  """Circuit breaker pattern for protecting external API calls."""

  import threading
  import time
  from enum import Enum


  class CircuitBreakerState(Enum):
      CLOSED = "closed"
      OPEN = "open"
      HALF_OPEN = "half_open"


  class CircuitBreakerOpen(Exception):
      """Raised when the circuit breaker is open and blocking requests."""

      def __init__(self, retry_after: float) -> None:
          self.retry_after = retry_after
          super().__init__(f"Circuit breaker is open. Retry after {retry_after:.1f}s")


  def _is_permanent_error(error: Exception) -> bool:
      """Return True if the error is permanent (should open circuit immediately)."""
      try:
          from classify_api_error import classify_api_error

          return classify_api_error(error) == "permanent"
      except ImportError:
          # Fallback: inspect error text directly
          text = str(error)
          if error.__cause__ is not None:
              text += " " + str(error.__cause__)
          permanent_patterns = ["400", "401", "403", "Forbidden", "Unauthorized"]
          return any(p in text for p in permanent_patterns)


  class CircuitBreaker:
      """Thread-safe circuit breaker for protecting external API calls.

      Implements three states:
      - CLOSED: requests pass through normally.
      - OPEN: requests are blocked with CircuitBreakerOpen.
      - HALF_OPEN: one probe request is allowed through.

      Args:
          failure_threshold: Consecutive failures before opening. Default 5.
          reset_timeout: Seconds to wait in OPEN before trying HALF_OPEN. Default 300.0.
      """

      def __init__(
          self,
          failure_threshold: int = 5,
          reset_timeout: float = 300.0,
      ) -> None:
          self._failure_threshold = failure_threshold
          self._reset_timeout = reset_timeout
          self._lock = threading.Lock()

          self._state = CircuitBreakerState.CLOSED
          self._failure_count = 0
          self._opened_at: float | None = None

      # ------------------------------------------------------------------
      # Public interface
      # ------------------------------------------------------------------

      def check(self) -> None:
          """Check whether a request is allowed through.

          Raises:
              CircuitBreakerOpen: If the circuit is open and reset_timeout
                  has not elapsed yet.
          """
          with self._lock:
              if self._state is CircuitBreakerState.CLOSED:
                  return

              if self._state is CircuitBreakerState.OPEN:
                  elapsed = time.monotonic() - self._opened_at  # type: ignore[operator]
                  if elapsed >= self._reset_timeout:
                      self._state = CircuitBreakerState.HALF_OPEN
                      return
                  remaining = self._reset_timeout - elapsed
                  raise CircuitBreakerOpen(min(remaining, 30.0))

              # HALF_OPEN: allow exactly one probe — caller holds the slot
              if self._state is CircuitBreakerState.HALF_OPEN:
                  return

      def record_success(self) -> None:
          """Record a successful request. Resets the breaker to CLOSED."""
          with self._lock:
              self._state = CircuitBreakerState.CLOSED
              self._failure_count = 0
              self._opened_at = None

      def record_failure(self, error: Exception) -> None:
          """Record a failed request.

          If the error is permanent (e.g. 401/403), opens immediately.
          Otherwise increments the failure counter and opens once it
          reaches failure_threshold.

          Args:
              error: The exception that was raised.
          """
          with self._lock:
              if _is_permanent_error(error):
                  self._trip()
                  return

              if self._state is CircuitBreakerState.HALF_OPEN:
                  self._trip()
                  return

              self._failure_count += 1
              if self._failure_count >= self._failure_threshold:
                  self._trip()

      @property
      def retry_after(self) -> float:
          """Seconds until the circuit transitions to HALF_OPEN.

          Returns 0.0 when not in OPEN state, capped at 30 seconds.
          """
          with self._lock:
              if self._state is not CircuitBreakerState.OPEN:
                  return 0.0
              elapsed = time.monotonic() - self._opened_at  # type: ignore[operator]
              remaining = self._reset_timeout - elapsed
              return min(max(remaining, 0.0), 30.0)

      # ------------------------------------------------------------------
      # Internal helpers
      # ------------------------------------------------------------------

      def _trip(self) -> None:
          """Open the circuit (must be called with _lock held)."""
          self._state = CircuitBreakerState.OPEN
          self._failure_count = 0
          self._opened_at = time.monotonic()

params_schema:
  {"params":[{"name":"failure_threshold","desc":"numero de fallos consecutivos que dispara el estado OPEN"},{"name":"reset_timeout","desc":"tiempo en segundos que debe esperar antes de pasar a HALF_OPEN"}],"output":"instancia de CircuitBreaker con metodos check(), record_success(), record_failure(), retry_after"}


id: classify_api_error_py_core
name: classify_api_error
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def classify_api_error(error: Exception) -> str
tags: ["retry","error","classification","api","backoff"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/core/classify_api_error_test.py
file_path: python/functions/core/classify_api_error.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: acebfbd4a4bbc25388247fa98202ff5f10dced1bb6f46420984ce8b7c03006fd
description:
  Clasifica un error de API como permanente (no reintentar), transitorio (reintentar) o desconocido. Permanente tiene prioridad sobre transitorio.

example:
  ```python
  err = Exception("HTTP 429 TooManyRequests")
  classify_api_error(err)  # "transient"

  err = Exception("HTTP 401 Unauthorized")
  classify_api_error(err)  # "permanent"

  err = Exception("Connection timeout")
  classify_api_error(err)  # "transient"

  err = Exception("Something unexpected happened")
  classify_api_error(err)  # "unknown"
  ```

tests:
  ["error 429 es transitorio","error 401 es permanente","error timeout es transitorio","error desconocido retorna unknown","error con __cause__ transitorio"]

notes:
  Funcion pura: solo inspecciona el texto del error y su causa directa (`__cause__`). No tiene I/O ni dependencias externas. La prioridad permanente > transitorio evita reintentar errores 400/401/403 que nunca tendran exito.

code:
  """Classify an API exception as permanent, transient, or unknown."""


  def classify_api_error(error: Exception) -> str:
      """Classify an API error as permanent, transient, or unknown.

      Permanent errors should not be retried (e.g. auth failures, bad requests).
      Transient errors are safe to retry (e.g. rate limits, timeouts, server errors).
      Permanent classification takes priority over transient.

      Args:
          error: The exception to classify.

      Returns:
          "permanent" | "transient" | "unknown"
      """
      parts = [str(error)]
      if error.__cause__ is not None:
          parts.append(str(error.__cause__))
      text = " ".join(parts)

      permanent_patterns = ["400", "401", "403", "Forbidden", "Unauthorized"]
      transient_patterns = [
          "429", "500", "502", "503", "504",
          "TooManyRequests", "RateLimit",
          "timeout", "Timeout",
          "ConnectionError", "Connection refused", "Connection reset",
      ]

      for pattern in permanent_patterns:
          if pattern in text:
              return "permanent"

      for pattern in transient_patterns:
          if pattern in text:
              return "transient"

      return "unknown"

params_schema:
  {"params":[{"name":"error","desc":"excepcion de Python (Exception o subclase) a clasificar"}],"output":"string: 'permanent' (no reintentar), 'transient' (reintentar), o 'unknown'"}


id: coerce_types_py_core
name: coerce_types
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def coerce_types(data: dict, schema: dict[str, str]) -> tuple[dict, list[str]]
tags: ["coercion","types","normalization","pure","core","csv","json"]
uses_functions: []
uses_types: []
returns: []
imports: ["datetime"]
test_file_path: python/functions/core/coerce_types_test.py
file_path: python/functions/core/coerce_types.py
created_at: 2026-04-05T11:24:00Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 08b428f780fe94f7ac5ff6d00439be2883acb13abbdbb92a063aa034f03affee
description:
  Convierte valores de un dict a los tipos esperados segun un schema declarativo. Soporta int, float, str, bool, datetime, list[str]. Util para normalizar datos de CSV, JSON o query params. Nunca muta el original. Coerciones imposibles generan warning y mantienen el valor original.

example:
  ```python
  data = {"age": "25", "score": "9.5", "active": "yes", "tags": "go, python"}
  schema = {"age": "int", "score": "float", "active": "bool", "tags": "list[str]"}

  result, warnings = coerce_types(data, schema)
  # result = {"age": 25, "score": 9.5, "active": True, "tags": ["go", "python"]}
  # warnings = []

  # Coercion fallida — mantiene original y avisa
  result2, warnings2 = coerce_types({"n": "abc"}, {"n": "int"})
  # result2 = {"n": "abc"}
  # warnings2 = ["n: cannot coerce 'abc' to int: could not convert string to float: 'abc'"]
  ```

tests:
  ["string 42 a int 42","string 3.14 a float 3.14","string true a bool true","string iso8601 a datetime","coercion fallida genera warning sin crash","dict con mix de tipos ya correctos y strings","campo ausente en schema pass through sin tocar","string lista a list str"]

notes:
  Funcion pura. Solo usa `datetime` de la stdlib. No muta el dict original — retorna uno nuevo. Schema es flat (no anidado); para validacion de estructura compleja combinar con `validate_json_schema`. Lossy coercions (float "3.7" → int 3) generan warning adicional. Campo ausente en schema se copia sin tocar.

code:
  """Coercion de valores de un dict a tipos esperados segun un schema declarativo."""

  from datetime import datetime, timezone


  def coerce_types(
      data: dict, schema: dict[str, str]
  ) -> tuple[dict, list[str]]:
      """Convierte valores de un dict a los tipos esperados segun el schema.

      Schema es un dict de {campo: tipo} donde tipo es uno de:
      "int", "float", "str", "bool", "datetime", "list[str]".

      Coerciones soportadas (todas desde str):
      - str → int: int(v), warning si tenia decimales
      - str → float: float(v)
      - str → bool: "true/1/yes" → True, "false/0/no" → False (case-insensitive)
      - str → datetime: ISO 8601 parse
      - str → list[str]: split por "," y strip de cada elemento
      - Valor ya del tipo correcto → pass through
      - Campo ausente en schema → pass through sin tocar
      - Coercion imposible → mantener original + warning

      Args:
          data: Dict con los valores a coercionar.
          schema: Dict de {campo: tipo_esperado}.

      Returns:
          (coerced_data, warnings) — nuevo dict con tipos corregidos (no muta el
          original), lista de warnings para coerciones lossy o fallidas.
      """
      result = dict(data)
      warnings: list[str] = []

      for field, target_type in schema.items():
          if field not in data:
              continue

          value = data[field]
          try:
              result[field] = _coerce_value(value, target_type, field, warnings)
          except Exception as exc:
              warnings.append(
                  f"{field}: cannot coerce {value!r} to {target_type}: {exc}"
              )
              result[field] = value

      return result, warnings


  _BOOL_TRUE = {"true", "1", "yes"}
  _BOOL_FALSE = {"false", "0", "no"}


  def _coerce_value(
      value: object, target: str, field: str, warnings: list[str]
  ) -> object:
      # --- int ---
      if target == "int":
          if isinstance(value, int) and not isinstance(value, bool):
              return value
          if isinstance(value, float):
              if value != int(value):
                  warnings.append(
                      f"{field}: lossy coercion float→int: {value} → {int(value)}"
                  )
              return int(value)
          if isinstance(value, str):
              stripped = value.strip()
              # detectar si tiene parte decimal no cero
              try:
                  as_float = float(stripped)
                  if as_float != int(as_float):
                      warnings.append(
                          f"{field}: lossy coercion str→int: {value!r} → {int(as_float)}"
                      )
                  return int(as_float)
              except ValueError:
                  raise ValueError(f"cannot parse {value!r} as int")
          raise TypeError(f"cannot coerce {type(value).__name__} to int")

      # --- float ---
      if target == "float":
          if isinstance(value, float):
              return value
          if isinstance(value, int) and not isinstance(value, bool):
              return float(value)
          if isinstance(value, str):
              return float(value.strip())
          raise TypeError(f"cannot coerce {type(value).__name__} to float")

      # --- str ---
      if target == "str":
          if isinstance(value, str):
              return value
          return str(value)

      # --- bool ---
      if target == "bool":
          if isinstance(value, bool):
              return value
          if isinstance(value, str):
              low = value.strip().lower()
              if low in _BOOL_TRUE:
                  return True
              if low in _BOOL_FALSE:
                  return False
              raise ValueError(
                  f"cannot parse {value!r} as bool; expected true/false/1/0/yes/no"
              )
          if isinstance(value, int):
              return bool(value)
          raise TypeError(f"cannot coerce {type(value).__name__} to bool")

      # --- datetime ---
      if target == "datetime":
          if isinstance(value, datetime):
              return value
          if isinstance(value, str):
              s = value.strip()
              # Intentar parse ISO 8601 con y sin Z
              if s.endswith("Z"):
                  s = s[:-1] + "+00:00"
              return datetime.fromisoformat(s)
          raise TypeError(f"cannot coerce {type(value).__name__} to datetime")

      # --- list[str] ---
      if target == "list[str]":
          if isinstance(value, list):
              return [str(item) for item in value]
          if isinstance(value, str):
              return [item.strip() for item in value.split(",")]
          raise TypeError(f"cannot coerce {type(value).__name__} to list[str]")

      raise ValueError(f"unknown target type: {target!r}")

params_schema:
  {"params":[{"name":"data","desc":"dict con valores a coercionar (tipicamente strings)"},{"name":"schema","desc":"dict con campos como keys y tipos esperados como values (int, float, str, bool, datetime, list[str])"}],"output":"tupla (resultado_coercionado, lista_de_warnings)"}


id: compose_py_core
name: compose
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def compose(*fns) -> callable
description: Compone funciones de derecha a izquierda. compose(f, g)(x) == f(g(x)).
tags: ["functional","compose","composition","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```python
  double_then_str = compose(str, lambda n: n * 2)
  result = double_then_str(5)
  # "10"
  ```

tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Funcion pura. Composicion matematica: la ultima funcion se aplica primero. Inverso de pipe.
content_hash: 8f13520b4235704d3dc92a28e7382863cba13791fac664802c3c27a83cef6c3b
code:
[cell repeats an earlier one in this file: 25395 characters]

params_schema:
  {"params":[{"name":"fns","desc":"funciones a componer (variable number of arguments)"}],"output":"funcion compuesta que aplica las funciones de derecha a izquierda"}


id: compute_backoff_delay_py_core
name: compute_backoff_delay
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
tags: ["retry","backoff","exponential","delay","jitter"]
uses_functions: []
uses_types: []
returns: []
imports: ["random"]
test_file_path: python/functions/core/compute_backoff_delay_test.py
file_path: python/functions/core/compute_backoff_delay.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: feb4750b115658d30eabebd377d1ab61c4bf1ab93eaa2f61047be6fdfe826afe
signature:
  def compute_backoff_delay(attempt: int, base_delay: float = 0.5, max_delay: float = 8.0, jitter: bool = True) -> float

description:
  Calcula el delay para exponential backoff con jitter opcional. delay = min(base_delay * 2^attempt, max_delay). Con jitter anade random.uniform(0, min(base_delay, delay)).

example:
  ```python
  # Primer reintento (attempt=0): delay = 0.5 * 2^0 = 0.5s
  compute_backoff_delay(0, jitter=False)  # 0.5

  # Tercer reintento (attempt=2): delay = 0.5 * 2^2 = 2.0s
  compute_backoff_delay(2, jitter=False)  # 2.0

  # Intento alto, capped a 8.0s
  compute_backoff_delay(10, jitter=False)  # 8.0

  # Con jitter (no determinista)
  compute_backoff_delay(1)  # entre 1.0 y 1.5
  ```

tests:
  ["attempt 0 retorna base_delay sin jitter","attempt alto se cappea a max_delay","sin jitter es determinista"]

notes:
  Usa `random` de la stdlib. Con jitter=True el resultado no es determinista, pero la funcion es clasificada como pura conceptualmente dado que el jitter es intencional y no hay I/O. Para tests deterministicos usar jitter=False.

code:
  """Compute exponential backoff delay with optional jitter."""

  import random


  def compute_backoff_delay(
      attempt: int,
      base_delay: float = 0.5,
      max_delay: float = 8.0,
      jitter: bool = True,
  ) -> float:
      """Compute exponential backoff delay for a given attempt number.

      Args:
          attempt: Zero-based attempt index (0 = first retry).
          base_delay: Base delay in seconds before exponential scaling.
          max_delay: Maximum delay cap in seconds.
          jitter: If True, adds random jitter to avoid thundering herd.

      Returns:
          Delay in seconds to wait before the next attempt.
      """
      delay = min(base_delay * (2 ** attempt), max_delay)
      if jitter:
          delay += random.uniform(0, min(base_delay, delay))
      return delay

params_schema:
  {"params":[{"name":"attempt","desc":"numero de intento (0-based, siendo 0 el primer reintento)"},{"name":"base_delay","desc":"delay inicial en segundos"},{"name":"max_delay","desc":"delay maximo permitido en segundos"},{"name":"jitter","desc":"si True anade variacion aleatoria al delay"}],"output":"numero flotante representando el delay en segundos"}


id: convert_github_to_raw_url_py_core
name: convert_github_to_raw_url
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: convert_github_to_raw_url(url: str) -> str
tags: ["github","gitlab","url","raw","blob","convert","transform"]
uses_functions: []
uses_types: []
returns: []
imports: ["urllib.parse"]
test_file_path: python/functions/core/convert_github_to_raw_url_test.py
file_path: python/functions/core/convert_github_to_raw_url.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 17ad3b082e6b7ebce7539c97922d055b7c0206d6b6c2d674889059f1d58cb961
description:
  Convierte una URL de blob de GitHub/GitLab a su URL raw. Ej: github.com/org/repo/blob/main/file.py → raw.githubusercontent.com/org/repo/main/file.py. Retorna la URL sin cambios si no aplica.

example:
  ```python
  from core.convert_github_to_raw_url import convert_github_to_raw_url

  # GitHub blob → raw.githubusercontent.com
  url = convert_github_to_raw_url(
      "https://github.com/openai/whisper/blob/main/README.md"
  )
  # "https://raw.githubusercontent.com/openai/whisper/main/README.md"

  # GitLab blob → raw
  url = convert_github_to_raw_url(
      "https://gitlab.com/org/repo/-/blob/main/file.py"
  )
  # "https://gitlab.com/org/repo/-/raw/main/file.py"

  # URL sin blob → sin cambios
  url = convert_github_to_raw_url("https://github.com/org/repo")
  # "https://github.com/org/repo"
  ```

tests:
  ["URL GitHub blob","URL GitLab blob","URL que no es blob retorna sin cambios","URL no-GitHub retorna sin cambios"]

notes:
  Algoritmo:
  1. Parsear la URL con `urllib.parse.urlparse`.
  2. Si host es `github.com`: buscar segmento `blob` en el path.
     - Si existe: eliminar el segmento `blob` y cambiar el dominio a `raw.githubusercontent.com`.
  3. Si host es `gitlab.com` o empieza con `gitlab.`: reemplazar `/-/blob/` por `/-/raw/`
     o `/blob/` por `/raw/`.
  4. Cualquier otro host: retornar la URL sin cambios.

  Funcion pura. No hace I/O ni tiene efectos secundarios.

code:
  """Convierte URLs de blob de GitHub/GitLab a su equivalente raw."""

  from urllib.parse import urlparse, urlunparse


  def convert_github_to_raw_url(url: str) -> str:
      """Convierte una URL de blob de GitHub o GitLab a su URL raw.

      GitHub blob:
          https://github.com/org/repo/blob/main/path/file.py
          → https://raw.githubusercontent.com/org/repo/main/path/file.py

      GitLab blob:
          https://gitlab.com/org/repo/-/blob/main/path/file.py
          → https://gitlab.com/org/repo/-/raw/main/path/file.py

      Si la URL no contiene un path tipo blob, la retorna sin cambios.

      Args:
          url: URL de GitHub o GitLab, posiblemente apuntando a un blob.

      Returns:
          URL raw si aplica la transformacion; la URL original en caso contrario.
      """
      url = url.strip()
      if not url:
          return url

      parsed = urlparse(url)
      host = parsed.hostname or ""

      # --- GitHub ---
      if host in ("github.com", "www.github.com"):
          # Path tipico: /org/repo/blob/ref/path/to/file
          segments = parsed.path.split("/")
          if "blob" in segments:
              blob_idx = segments.index("blob")
              # Eliminar segmento "blob": /org/repo/ref/path/...
              new_segments = segments[:blob_idx] + segments[blob_idx + 1:]
              new_path = "/".join(new_segments)
              raw_url = urlunparse((
                  "https",
                  "raw.githubusercontent.com",
                  new_path,
                  parsed.params,
                  parsed.query,
                  parsed.fragment,
              ))
              return raw_url
          return url

      # --- GitLab ---
      if host in ("gitlab.com", "www.gitlab.com") or host.startswith("gitlab."):
          # Path tipico: /org/repo/-/blob/ref/path o /org/repo/blob/ref/path
          new_path = parsed.path.replace("/-/blob/", "/-/raw/").replace("/blob/", "/raw/")
          if new_path != parsed.path:
              raw_url = urlunparse((
                  parsed.scheme,
                  parsed.netloc,
                  new_path,
                  parsed.params,
                  parsed.query,
                  parsed.fragment,
              ))
              return raw_url
          return url

      # No aplica transformacion
      return url

params_schema:
  {"params":[{"name":"url","desc":"URL a convertir (puede ser GitHub, GitLab o cualquier otra)"}],"output":"string con la URL convertida a raw format (o sin cambios si no es un blob)"}


id: create_node_mapping_py_core
name: create_node_mapping
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def create_node_mapping(tree: list[dict]) -> dict[str, dict]
description: Crea dict plano node_id->node para lookup O(1) en un arbol jerarquico.
tags: ["tree","mapping","index","lookup"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Funcion pura. Los valores son referencias a los nodos originales, no copias.
content_hash: e5fef7bd40be8bd1510317545639f46113e24e78f247d4a859c3937a2756e037
source_repo: https://github.com/VectifyAI/PageIndex
source_license: MIT
source_file: pageindex/utils.py
example:
  ```python
  tree = [{"node_id": "0001", "title": "A", "nodes": [{"node_id": "0002", "title": "B"}]}]
  mapping = create_node_mapping(tree)
  mapping["0002"]["title"]  # "B"
  ```

code:
[cell repeats an earlier one in this file: 25395 characters]

params_schema:
  {"params":[{"name":"tree","desc":"lista de dicts representando arbol jerarquico con campos node_id y nodes"}],"output":"dict con node_id como keys y nodos como values para lookup O(1)"}


id: cursor_paginate_py_core
name: cursor_paginate
kind: function
lang: py
domain: core
version: 1.0.0
purity: impure
tags: ["pagination","cursor","retry","generic","api","backoff"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["time","typing.Callable","typing.TypeVar"]
test_file_path: python/functions/core/cursor_paginate_test.py
file_path: python/functions/core/cursor_paginate.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 47c1ed65683f24308ad060f6ff15cf2952e8d46cbdb51543a0809b9cca0dd6ec
signature:
  def cursor_paginate(fetch_page: Callable[..., list[T]], get_cursor: Callable[[T], str | None], page_size: int = 100, max_items: int = 2000, max_retries: int = 3, retry_delay: float = 2.0, retryable_exceptions: tuple[type[Exception], ...] = (ConnectionError, TimeoutError, OSError)) -> list[T]

description:
  Paginador generico basado en cursor que funciona con cualquier API que use cursor-based pagination. Cada pagina se obtiene con retry automatico con exponential backoff. Se detiene cuando la pagina esta vacia, el batch es menor que page_size, se alcanza max_items, o el cursor del ultimo item es None.

example:
  ```python
  from cursor_paginate import cursor_paginate

  def fetch_users(limit: int, cursor: str | None) -> list[dict]:
      params = {"limit": limit}
      if cursor:
          params["cursor"] = cursor
      return requests.get("https://api.example.com/users", params=params).json()["items"]

  def get_cursor(user: dict) -> str | None:
      return user.get("next_cursor")

  users = cursor_paginate(
      fetch_page=fetch_users,
      get_cursor=get_cursor,
      page_size=100,
      max_items=5000,
      max_retries=3,
      retry_delay=2.0,
  )
  ```

tests:
  ["API que retorna 3 paginas de 10 items","API que falla 1 vez por pagina (retry funciona)","max_items limita correctamente","API que retorna pagina parcial (ultima pagina)","Cursor None en ultimo item (se detiene)"]

notes:
  El caller solo necesita proveer dos callables:
  - `fetch_page(limit, cursor)`: recibe `limit` y `cursor` como kwargs, retorna lista de items.
  - `get_cursor(item)`: extrae el cursor del ultimo item de la pagina; retornar None indica fin de datos.

  El exponential backoff interno aplica `retry_delay * 2^attempt` sin jitter. Solo se reintentan las excepciones en `retryable_exceptions`; cualquier otra excepcion propaga inmediatamente.

  Condiciones de parada (cualquiera de ellas):
  1. La pagina retornada esta vacia.
  2. La pagina retornada tiene menos items que `page_size` (pagina parcial = ultima pagina).
  3. El total acumulado alcanza o supera `max_items` (se trunca y se para).
  4. `get_cursor(batch[-1])` retorna `None`.

  Funcion impura: llama a `fetch_page` que tipicamente hace I/O de red y usa `time.sleep` en los reintentos.

code:
  """Generic cursor-based paginator for any API that uses cursor pagination."""

  import time
  from typing import Callable, TypeVar

  T = TypeVar("T")


  def cursor_paginate(
      fetch_page: Callable[..., list[T]],
      get_cursor: Callable[[T], str | None],
      page_size: int = 100,
      max_items: int = 2000,
      max_retries: int = 3,
      retry_delay: float = 2.0,
      retryable_exceptions: tuple[type[Exception], ...] = (
          ConnectionError,
          TimeoutError,
          OSError,
      ),
  ) -> list[T]:
      """Paginate through a cursor-based API, collecting all items.

      Fetches pages one at a time by calling fetch_page with limit and cursor
      kwargs. Retries each page on transient errors using exponential backoff.
      Stops when a page is empty, a partial page is returned, max_items is
      reached, or the cursor from the last item is None.

      Args:
          fetch_page: Callable that accepts ``limit`` and ``cursor`` as keyword
              arguments and returns a list of items for that page.
          get_cursor: Callable that receives the last item of a page and returns
              the cursor string to use for the next page, or None if there are
              no more pages.
          page_size: Number of items to request per page.
          max_items: Hard cap on total items collected. Collection stops and the
              list is truncated once this limit is reached.
          max_retries: Maximum number of retry attempts per page after the first
              failure.
          retry_delay: Base delay in seconds between retries (doubled each
              attempt — exponential backoff without jitter).
          retryable_exceptions: Tuple of exception types that trigger a retry.
              Any other exception propagates immediately.

      Returns:
          List of all collected items, in the order they were returned by the
          API, truncated to max_items.

      Raises:
          Exception: Re-raises the last exception if all retries for a page are
              exhausted.
      """
      all_items: list[T] = []
      cursor: str | None = None

      while True:
          batch = _fetch_with_retry(
              fetch_page=fetch_page,
              page_size=page_size,
              cursor=cursor,
              max_retries=max_retries,
              retry_delay=retry_delay,
              retryable_exceptions=retryable_exceptions,
          )

          if not batch:
              break

          all_items.extend(batch)

          if len(all_items) >= max_items:
              del all_items[max_items:]
              break

          if len(batch) < page_size:
              break

          cursor = get_cursor(batch[-1])
          if cursor is None:
              break

      return all_items


  def _fetch_with_retry(
      fetch_page: Callable[..., list[T]],
      page_size: int,
      cursor: str | None,
      max_retries: int,
      retry_delay: float,
      retryable_exceptions: tuple[type[Exception], ...],
  ) -> list[T]:
      """Call fetch_page once, retrying on retryable_exceptions with exponential backoff."""
      last_exc: Exception | None = None
      for attempt in range(max_retries + 1):
          try:
              return fetch_page(limit=page_size, cursor=cursor)
          except retryable_exceptions as exc:
              last_exc = exc
              if attempt >= max_retries:
                  raise
              delay = retry_delay * (2 ** attempt)
              time.sleep(delay)

      raise last_exc  # unreachable; satisfies type checkers

params_schema:
  {"params":[{"name":"fetch_page","desc":"callable que recibe limit y cursor como kwargs, retorna lista de items"},{"name":"get_cursor","desc":"callable que recibe un item y retorna su cursor (o None si es el ultimo)"},{"name":"page_size","desc":"numero de items por pagina a solicitar"},{"name":"max_items","desc":"limite maximo total de items a acumular"},{"name":"max_retries","desc":"numero maximo de reintentos por pagina"},{"name":"retry_delay","desc":"delay inicial en segundos para exponential backoff entre reintentos"},{"name":"retryable_exceptions","desc":"tupla de tipos de excepcion que desencadenan reintento"}],"output":"lista de items recopilados de todas las paginas"}


