# functions

id: button_ts_ui
name: button
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Button(props: ButtonProps & VariantProps<typeof buttonVariants>): JSX.Element
tags: ["button","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/button.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onClick"]
framework: react
variant: ["default","outline","secondary","ghost","destructive","link"]
content_hash: 092cee02578ae6dc0bc83b0a5b00c4049b81d2ba1aaba4bbbb1adaf7211f2b9e
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/button.tsx
params_schema: {"output":"JSX.Element: botón renderizado con los estilos y comportamientos configurados"}
description:
  Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Mantine Button.

example:
  ```tsx
  <Button variant="outline" size="sm">Click me</Button>
  <Button variant="destructive">Delete</Button>
  <Button variant="ghost" size="icon"><TrashIcon /></Button>
  ```

props:
  [{"name":"variant","type":"'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'","required":false,"description":"Estilo visual del botón"},{"name":"size","type":"'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'","required":false,"description":"Tamaño del botón"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Componente base del sistema. Usa Mantine Button para accesibilidad completa (keyboard, ARIA). Las variantes se mapean a Mantine: default->filled, outline->outline, secondary->light, ghost->subtle, destructive->filled(red), link->transparent.

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

  type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
  type ButtonSize = 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'

  const variantMap: Record<ButtonVariant, { variant: string; color?: string }> = {
    default: { variant: 'filled' },
    outline: { variant: 'outline' },
    secondary: { variant: 'light' },
    ghost: { variant: 'subtle' },
    destructive: { variant: 'filled', color: 'red' },
    link: { variant: 'transparent' },
  }

  const sizeMap: Record<ButtonSize, { size: string; style?: React.CSSProperties }> = {
    default: { size: 'sm' },
    xs: { size: 'xs' },
    sm: { size: 'xs' },
    lg: { size: 'md' },
    icon: { size: 'sm', style: { width: 32, height: 32, padding: 0 } },
    'icon-xs': { size: 'xs', style: { width: 24, height: 24, padding: 0 } },
    'icon-sm': { size: 'xs', style: { width: 28, height: 28, padding: 0 } },
    'icon-lg': { size: 'md', style: { width: 36, height: 36, padding: 0 } },
  }

  /** Kept for backwards compatibility — maps variant names to Mantine equivalents */
  const buttonVariants = variantMap

  interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
    variant?: ButtonVariant
    size?: ButtonSize
    children?: React.ReactNode
  }

  function Button({
    className,
    variant = 'default',
    size = 'default',
    style,
    children,
    ...props
  }: ButtonProps) {
    const mv = variantMap[variant]
    const ms = sizeMap[size]

    return (
      <MantineButton
        data-slot="button"
        variant={mv.variant}
        color={mv.color}
        size={ms.size}
        radius="md"
        className={className}
        style={{ ...ms.style, ...style }}
        {...props}
      >
        {children}
      </MantineButton>
    )
  }

  export { Button, buttonVariants }
  export type { ButtonProps, ButtonVariant, ButtonSize }


id: card_ts_ui
name: card
kind: component
lang: ts
domain: ui
version: 1.1.0
purity: impure
tags: ["card","container","layout","component","ui","dashboard","dark"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/card.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["default","sm","borderless","ghost"]
content_hash: 3d507a1d09f63f802915ee3d6f0ab13a86849d1e368fbfd04a5fa2d6ce0fdd63
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/card.tsx
signature:
  Card(props: { size?: 'default' | 'sm'; variant?: 'default' | 'borderless' | 'ghost'; className?: string; children: ReactNode }): JSX.Element

description:
  Contenedor card con header, title, description, action, content y footer. Sistema de slots composable. Variantes default, borderless y ghost para dashboards dark.

example:
  ```tsx
  <Card>
    <CardHeader>
      <CardTitle>Título</CardTitle>
      <CardDescription>Descripción</CardDescription>
    </CardHeader>
    <CardContent>Contenido</CardContent>
    <CardFooter>Footer</CardFooter>
  </Card>

  {/* Dashboard dark — sin bordes */}
  <Card variant="borderless">
    <CardContent>Widget sin marco</CardContent>
  </Card>

  {/* Completamente transparente */}
  <Card variant="ghost">
    <CardContent>Sin fondo ni borde</CardContent>
  </Card>
  ```

props:
  [{"name":"size","type":"'default' | 'sm'","required":false,"description":"Tamaño del card"},{"name":"variant","type":"'default' | 'borderless' | 'ghost'","required":false,"description":"Variante visual. borderless quita borde/shadow, ghost además hace bg transparente"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables.

  Las variantes `borderless` y `ghost` eliminan el `ring-1` del borde por defecto. `ghost` además hace el fondo transparente. Alternativa con CSS global: `[data-slot="card"] { --tw-ring-opacity: 0; }` o `[data-variant="borderless"] { ring: 0 }` via `data-variant` attribute expuesto.

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

  type CardVariant = 'default' | 'borderless' | 'ghost'

  function Card({
    className,
    size = 'default',
    variant = 'default',
    children,
    ...props
  }: React.ComponentProps<'div'> & { size?: 'default' | 'sm'; variant?: CardVariant }) {
    return (
      <Paper
        data-slot="card"
        data-size={size}
        data-variant={variant}
        withBorder={variant === 'default'}
        shadow={variant === 'default' ? 'xs' : undefined}
        radius="md"
        p={size === 'sm' ? 'sm' : 'md'}
        bg={variant === 'ghost' ? 'transparent' : undefined}
        className={className}
        {...props}
      >
        {children}
      </Paper>
    )
  }

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

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

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

  function CardAction({ className, style, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-action"
        style={{ position: 'absolute', top: 0, right: 0, ...style }}
        className={className}
        {...props}
      />
    )
  }

  function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-content"
        className={className}
        {...props}
      />
    )
  }

  function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-footer"
        pt="sm"
        mt="auto"
        style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
        className={className}
        {...props}
      />
    )
  }

  export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

params_schema:
  {"output":"Componente Card que renderiza un contenedor con slots composables (header, content, footer) y 3 variantes visuales"}


id: chart_container_ts_ui
name: chart_container
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element
description: Thin wrapper Paper y utilidades de colores/series para los charts @mantine/charts.
tags: ["chart","container","mantine","base","visualization","component","ui"]
uses_functions: []
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/chart_container.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default"]
content_hash: 289ff310f93689519a41311225239b7463e0cf44c817278c0404792b991386dd
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/chart-base.tsx
example:
  ```tsx
  import { ChartContainer, getSeriesColor, type Series } from './chart_container'

  <ChartContainer height={400}>
    <MantineLineChart ... />
  </ChartContainer>
  ```

props:
  [{"name":"height","type":"number | string","required":false,"description":"Altura del chart (default 300)"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Exporta: ChartContainer, defaultColors, getSeriesColor, Series. Wrapper fino sobre Mantine Paper para layout uniforme de charts.

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

  export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']

  export interface Series {
    key: string
    name: string
    color?: string
  }

  export function getSeriesColor(index: number, color?: string): string {
    return color || defaultColors[index % defaultColors.length]!
  }

  interface ChartContainerProps {
    children: React.ReactNode
    height?: number | string
  }

  export function ChartContainer({ children, height = 300 }: ChartContainerProps) {
    return (
      <Paper p="md" style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}>
        {children}
      </Paper>
    )
  }

  /** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
  export function ChartTooltipContent() { return null }
  /** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
  export function ChartTooltip() { return null }
  /** @deprecated Mantine charts handle legends internally. Kept for index.ts compat. */
  export function ChartLegend() { return null }

params_schema:
  {"output":"Componente ChartContainer Paper wrapper y utilidades getSeriesColor/Series para charts Mantine"}


id: checkbox_ts_ui
name: checkbox
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Checkbox(props: CheckboxProps): JSX.Element
description: Input booleano accesible con label opcional y variante indeterminate. Mantine Checkbox.
tags: ["checkbox","component","ui","interactive","form","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/checkbox.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onCheckedChange"]
framework: react
variant: []
content_hash: 8196320b87dfd584dcb6195f14125fa6a6427001d058586f3e627e02c13c573f
example:
  ```tsx
  // Basico
  <Checkbox label="Acepto los terminos" />

  // Controlado
  <Checkbox
    label="Seleccionar todos"
    checked={allSelected}
    indeterminate={someSelected}
    onCheckedChange={setAllSelected}
  />

  // Sin label
  <Checkbox checked={isActive} onCheckedChange={setIsActive} />
  ```

props:
  [{"name":"label","type":"string","required":false,"description":"Texto de etiqueta visible junto al checkbox"},{"name":"indeterminate","type":"boolean","required":false,"description":"Estado indeterminate (guion) en vez de checked/unchecked"},{"name":"checked","type":"boolean","required":false,"description":"Estado controlado del checkbox"},{"name":"defaultChecked","type":"boolean","required":false,"description":"Estado inicial no controlado"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el checkbox"},{"name":"onCheckedChange","type":"(checked: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"}]

notes:
  Usa Mantine Checkbox para accesibilidad completa (keyboard, ARIA). El estado indeterminate se muestra con un guion horizontal. El callback onCheckedChange se adapta desde el onChange nativo de Mantine.

code:
  import { Checkbox as MantineCheckbox } from "@mantine/core"

  interface CheckboxProps {
    label?: string
    indeterminate?: boolean
    className?: string
    labelClassName?: string
    checked?: boolean
    defaultChecked?: boolean
    disabled?: boolean
    onCheckedChange?: (checked: boolean) => void
    id?: string
  }

  function Checkbox({ className, label, id, indeterminate, checked, defaultChecked, disabled, onCheckedChange, ...props }: CheckboxProps) {
    return (
      <MantineCheckbox
        id={id}
        data-slot="checkbox"
        label={label}
        indeterminate={indeterminate}
        checked={checked}
        defaultChecked={defaultChecked}
        disabled={disabled}
        onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
        className={className}
        size="sm"
        {...props}
      />
    )
  }

  export { Checkbox }
  export type { CheckboxProps }

params_schema:
  {"output":"Componente Checkbox que renderiza input booleano accesible con label opcional y estado indeterminate"}


id: chip_ts_ui
name: chip
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Chip(props: ChipProps): JSX.Element
tags: ["chip","toggle","selection","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/chip.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["filled","outline","light"]
content_hash: bb6ff52eda5c53b81f97c635e9e60816dceb3510f83d2f79596e840b1f2864a4
description:
  Chip seleccionable con variantes filled/outline/light. ChipGroup para selección simple o múltiple. Wrapper sobre Mantine Chip.

example:
  ```tsx
  import { Chip, ChipGroup } from '@fn_library'

  // Chip individual controlado
  <Chip checked={active} onChange={setActive}>
    Activo
  </Chip>

  // ChipGroup selección simple (una sola opción)
  <ChipGroup value={selected} onChange={setSelected}>
    <Chip value="react">React</Chip>
    <Chip value="vue">Vue</Chip>
    <Chip value="svelte">Svelte</Chip>
  </ChipGroup>

  // ChipGroup selección múltiple
  <ChipGroup multiple value={tags} onChange={setTags}>
    <Chip value="frontend">Frontend</Chip>
    <Chip value="backend">Backend</Chip>
    <Chip value="devops">DevOps</Chip>
  </ChipGroup>

  // Con variante y color custom
  <Chip variant="outline" color="teal" size="lg">
    Destacado
  </Chip>
  ```

props:
  [{"name":"checked","type":"boolean","required":false,"description":"Estado seleccionado del chip (controlled)"},{"name":"onChange","type":"(checked: boolean) =\u003e void","required":false,"description":"Callback al cambiar el estado del chip"},{"name":"variant","type":"'filled' | 'outline' | 'light'","required":false,"description":"Estilo visual del chip"},{"name":"color","type":"string","required":false,"description":"Color del chip cuando está seleccionado"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del chip"},{"name":"children","type":"React.ReactNode","required":true,"description":"Contenido del chip — texto o elemento"}]

notes:
  - Wrapper directo sobre `Chip` de `@mantine/core` v9. Todas las props de Mantine Chip son válidas.
  - `ChipGroup` es un alias de `MantineChip.Group` — gestiona el estado de selección entre chips hijos.
  - En `ChipGroup` sin `multiple`, `value` es `string`. Con `multiple`, `value` es `string[]`.
  - Internamente cada `Chip` renderiza un checkbox/radio accesible oculto con label visual.

code:
  import { Chip as MantineChip, type ChipProps as MantineChipProps } from '@mantine/core'

  interface ChipProps extends MantineChipProps {}

  function Chip(props: ChipProps) {
    return <MantineChip {...props} />
  }

  const ChipGroup = MantineChip.Group

  export { Chip, ChipGroup }
  export type { ChipProps }

params_schema:
  {"output":"Componente Chip que renderiza un elemento seleccionable tipo badge, con ChipGroup para gestionar selección simple o múltiple entre varios chips"}


id: color_input_ts_ui
name: color_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ColorInput(props: ColorInputProps): JSX.Element
tags: ["color","picker","input","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/color_input.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onChange","onChangeEnd"]
framework: react
variant: []
content_hash: 09723ceffef9a2b9b2e7de24b2f716884647599c9c7ea6ebf193e7e7cd395136
params_schema: {"output":"Componente ColorInput que renderiza input de color con picker desplegable y swatches"}
description:
  Selector de color con picker, swatches predefinidos y eye dropper. Soporta hex, rgb, hsl con alpha. Wrapper sobre Mantine ColorInput.

example:
  ```tsx
  import { ColorInput } from '@fn_library/color_input'

  // Basico con hex (default)
  <ColorInput
    label="Color primario"
    placeholder="#228be6"
  />

  // Con swatches predefinidos
  <ColorInput
    label="Color de marca"
    swatches={['#2e2e2e', '#868e96', '#fa5252', '#e64980', '#be4bdb', '#228be6', '#15aabf', '#12b886', '#40c057', '#82c91e', '#fab005', '#fd7e14']}
    swatchesPerRow={7}
    placeholder="Elige un color"
  />

  // Con rgba y eye dropper
  <ColorInput
    label="Color con transparencia"
    format="rgba"
    withEyeDropper
    value="rgba(34, 139, 230, 0.5)"
    onChange={(value) => console.log(value)}
  />
  ```

props:
  [{"name":"format","type":"\"hex\" | \"hexa\" | \"rgb\" | \"rgba\" | \"hsl\" | \"hsla\"","required":false,"description":"Formato de color a usar en el valor. Por defecto hex."},{"name":"swatches","type":"string[]","required":false,"description":"Lista de colores predefinidos mostrados como swatches en el picker."},{"name":"withEyeDropper","type":"boolean","required":false,"description":"Muestra el boton eye dropper para seleccionar color de la pantalla."},{"name":"withPicker","type":"boolean","required":false,"description":"Muestra el color picker interactivo. Por defecto true."},{"name":"value","type":"string","required":false,"description":"Valor controlado del color en el formato especificado."},{"name":"onChange","type":"(value: string) =\u003e void","required":false,"description":"Callback invocado cuando el color cambia."},{"name":"label","type":"React.ReactNode","required":false,"description":"Etiqueta del campo."},{"name":"placeholder","type":"string","required":false,"description":"Placeholder del input de texto."},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input."},{"name":"size","type":"\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","required":false,"description":"Tamanio del componente."}]

notes:
  Wrapper directo sobre `ColorInput` de Mantine v9. Hereda todas las props de Mantine sin restricciones.

  El eye dropper (`withEyeDropper`) solo funciona en browsers que soporten la EyeDropper API (Chrome/Edge). En Firefox no aparece el boton automaticamente.

  Cuando `format` es `hex` o `hsl`, el valor no incluye canal alpha. Para transparencia usar `hexa`, `rgba` o `hsla`.

code:
  import { ColorInput as MantineColorInput, type ColorInputProps as MantineColorInputProps } from '@mantine/core'

  interface ColorInputProps extends MantineColorInputProps {}

  function ColorInput(props: ColorInputProps) {
    return <MantineColorInput {...props} />
  }

  export { ColorInput }
  export type { ColorInputProps }


id: command_ts_ui
name: command
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Command(props: CommandProps): JSX.Element
tags: ["command","search","combobox","component","ui","interactive"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/command.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: ["onValueChange"]
framework: react
variant: []
content_hash: 328d257adbc7692eca328e9f184ed9a300c7d81ccf87ed99f943e48b296acb32
description:
  Combobox de busqueda y seleccion estilo cmdk. Filtra items por query, soporta grupos, iconos y shortcuts. Incluye CommandSearch para uso de una linea.

example:
  ```tsx
  // Uso simple con CommandSearch
  const items = [
    { value: "react", label: "React", group: "Frameworks" },
    { value: "vue", label: "Vue", group: "Frameworks" },
    { value: "typescript", label: "TypeScript", group: "Lenguajes" },
  ]

  <CommandSearch
    items={items}
    placeholder="Buscar tecnologia..."
    onValueChange={(val) => console.log(val)}
  />

  // Composable para mayor control
  <Command>
    <CommandInput placeholder="Buscar..." value={query} onChange={(e) => setQuery(e.target.value)} />
    <CommandList>
      <CommandEmpty>Sin resultados.</CommandEmpty>
      <CommandGroup heading="Sugerencias">
        <CommandItem selected={selected === "1"} onSelect={() => setSelected("1")}>
          Opcion 1
          <CommandShortcut>⌘K</CommandShortcut>
        </CommandItem>
      </CommandGroup>
    </CommandList>
  </Command>
  ```

props:
  [{"name":"items","type":"CommandItem[]","required":true,"description":"Array de items con value, label, description, icon, disabled, group"},{"name":"value","type":"string","required":false,"description":"Valor seleccionado (controlado)"},{"name":"onValueChange","type":"(value: string) =\u003e void","required":false,"description":"Callback al seleccionar un item"},{"name":"placeholder","type":"string","required":false,"description":"Placeholder del input de busqueda (default: Search...)"},{"name":"emptyMessage","type":"string","required":false,"description":"Mensaje cuando no hay resultados (default: No results found.)"}]

notes:
  Implementacion propia (sin dependencia de cmdk) usando primitivos HTML nativos. CommandSearch es el wrapper de alto nivel con filtrado reactivo integrado. El filtrado es case-insensitive sobre label, description y value. Los grupos se renderizan en orden de aparicion en items.

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

  interface CommandItemData {
    value: string
    label: string
    description?: string
    icon?: React.ReactNode
    disabled?: boolean
    group?: string
  }

  interface CommandProps {
    items: CommandItemData[]
    value?: string
    onValueChange?: (value: string) => void
    placeholder?: string
    emptyMessage?: string
    className?: string
    inputClassName?: string
    listClassName?: string
  }

  function Command({ className, children, ...props }: React.ComponentPropsWithoutRef<'div'>) {
    return <Box data-slot="command" className={className} {...props}>{children}</Box>
  }

  function CommandInput({ className, value, onChange, placeholder, ...props }: {
    className?: string
    value?: string
    onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
    placeholder?: string
  }) {
    return (
      <TextInput
        data-slot="command-input"
        leftSection={<IconSearch size={16} />}
        className={className}
        value={value}
        onChange={onChange}
        placeholder={placeholder}
        styles={{ input: { border: 'none', borderBottom: '1px solid var(--mantine-color-default-border)' } }}
        {...props}
      />
    )
  }

  function CommandList({ className, children }: { className?: string; children?: React.ReactNode }) {
    return (
      <ScrollArea.Autosize mah={300} data-slot="command-list" className={className}>
        {children}
      </ScrollArea.Autosize>
    )
  }

  function CommandEmpty({ className, children }: { className?: string; children?: React.ReactNode }) {
    return (
      <Text ta="center" c="dimmed" size="sm" py="xl" data-slot="command-empty" className={className}>
        {children}
      </Text>
    )
  }

  function CommandGroup({ className, heading, children }: { className?: string; heading?: string; children?: React.ReactNode }) {
    return (
      <Box data-slot="command-group" p={4} className={className}>
        {heading && <Text size="xs" fw={500} c="dimmed" px="sm" py={6}>{heading}</Text>}
        <div>{children}</div>
      </Box>
    )
  }

  function CommandSeparator({ className }: { className?: string }) {
    return <Box data-slot="command-separator" h={1} bg="var(--mantine-color-default-border)" mx={-4} className={className} />
  }

  function CommandItem({ className, selected, disabled, onSelect, children }: {
    className?: string
    selected?: boolean
    disabled?: boolean
    onSelect?: () => void
    children?: React.ReactNode
  }) {
    return (
      <Box
        data-slot="command-item"
        data-selected={selected}
        aria-disabled={disabled}
        role="option"
        aria-selected={selected}
        onClick={!disabled ? onSelect : undefined}
        px="sm"
        py={6}
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: 8,
          borderRadius: 'var(--mantine-radius-sm)',
          cursor: disabled ? 'not-allowed' : 'pointer',
          opacity: disabled ? 0.5 : 1,
          backgroundColor: selected ? 'var(--mantine-color-default-hover)' : undefined,
          fontSize: 'var(--mantine-font-size-sm)',
        }}
        className={className}
      >
        {children}
      </Box>
    )
  }

  function CommandShortcut({ className, children }: { className?: string; children?: React.ReactNode }) {
    return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
  }

  function CommandSearch({
    items,
    value,
    onValueChange,
    placeholder = 'Search...',
    emptyMessage = 'No results found.',
    className,
  }: CommandProps) {
    const [query, setQuery] = React.useState('')
    const [selectedValue, setSelectedValue] = React.useState(value ?? '')

    const filtered = React.useMemo(() => {
      if (!query) return items
      const q = query.toLowerCase()
      return items.filter(
        (item) =>
          item.label.toLowerCase().includes(q) ||
          item.description?.toLowerCase().includes(q) ||
          item.value.toLowerCase().includes(q)
      )
    }, [items, query])

    const groups = React.useMemo(() => {
      const map = new Map<string, CommandItemData[]>()
      for (const item of filtered) {
        const key = item.group ?? ''
        if (!map.has(key)) map.set(key, [])
        map.get(key)!.push(item)
      }
      return map
    }, [filtered])

    const handleSelect = (val: string) => {
      setSelectedValue(val)
      onValueChange?.(val)
    }

    return (
      <Command className={className}>
        <CommandInput
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={placeholder}
        />
        <CommandList>
          {filtered.length === 0 ? (
            <CommandEmpty>{emptyMessage}</CommandEmpty>
          ) : (
            Array.from(groups.entries()).map(([group, groupItems]) => (
              <CommandGroup key={group} heading={group || undefined}>
                {groupItems.map((item) => (
                  <CommandItem
                    key={item.value}
                    selected={selectedValue === item.value}
                    disabled={item.disabled}
                    onSelect={() => handleSelect(item.value)}
                  >
                    {item.icon && <span>{item.icon}</span>}
                    <span>{item.label}</span>
                    {item.description && (
                      <Text span size="xs" c="dimmed" ml="auto">{item.description}</Text>
                    )}
                  </CommandItem>
                ))}
              </CommandGroup>
            ))
          )}
        </CommandList>
      </Command>
    )
  }

  export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut }
  export type { CommandItemData, CommandProps }

params_schema:
  {"output":"Componente Command que renderiza combobox de búsqueda y selección con filtrado reactivo, grupos e iconos"}


id: crud_page_ts_ui
name: crud_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: crudPage<T>(props: CrudPageProps<T>): ReactElement
tags: ["crud","page","table","form","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/crud_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: fbdf8ffd14118a1551b4cc9a709c2dcdda146f387736e3849c6a048a59ce6fc0
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera una página CRUD completa con header, tabla con columnas configurables, botones de acción (add/edit/delete) y schema de formulario.

example:
  ```tsx
  crudPage({
    title: 'Users',
    subtitle: 'Manage system users',
    data: users,
    fields: [
      { key: 'name', label: 'Name', type: 'text', required: true },
      { key: 'email', label: 'Email', type: 'email', required: true },
      { key: 'role', label: 'Role', type: 'select', options: [{ label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }] },
    ],
    columns: [
      { key: 'name', label: 'Name' },
      { key: 'email', label: 'Email' },
      { key: 'role', label: 'Role', render: (v) => <Badge variant={v === 'admin' ? 'default' : 'secondary'}>{v}</Badge> },
    ],
    onAdd: handleAdd,
    onEdit: handleEdit,
    onDelete: handleDelete,
  })
  ```

notes:
  El schema de campos se almacena como data attribute para que un agente pueda leerlo y generar el formulario de diálogo correspondiente. La tabla incluye sorting visual implícito por columnas.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, Paper, Table, Button, ActionIcon, Center } from '@mantine/core'
  import { IconPlus, IconPencil, IconTrash } from '@tabler/icons-react'

  interface CrudField {
    key: string
    label: string
    type: 'text' | 'number' | 'email' | 'select' | 'textarea'
    required?: boolean
    options?: Array<{ label: string; value: string }>
    placeholder?: string
  }

  interface CrudPageProps<T extends Record<string, unknown>> {
    title: string
    subtitle?: string
    data: T[]
    fields: CrudField[]
    columns: Array<{
      key: keyof T
      label: string
      render?: (value: unknown, row: T) => React.ReactNode
    }>
    onAdd?: (item: Partial<T>) => void
    onEdit?: (item: T) => void
    onDelete?: (item: T) => void
    actions?: React.ReactNode
    className?: string
  }

  export function crudPage<T extends Record<string, unknown>>({
    title,
    subtitle,
    data,
    fields,
    columns,
    onAdd,
    onEdit,
    onDelete,
    actions,
  }: CrudPageProps<T>): React.ReactElement {
    return (
      <Stack gap="lg">
        {/* Header */}
        <Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Stack gap={4}>
            <Title order={2}>{title}</Title>
            {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
          </Stack>
          <Group gap="xs">
            {actions}
            {onAdd && (
              <Button size="xs" leftSection={<IconPlus size={16} />}>
                Add {title.replace(/s$/, '')}
              </Button>
            )}
          </Group>
        </Group>

        {/* Table */}
        <Paper withBorder radius="md">
          <Table highlightOnHover>
            <Table.Thead>
              <Table.Tr>
                {columns.map((col) => (
                  <Table.Th key={String(col.key)} fz="sm" fw={500} c="dimmed" px="md" py="sm">
                    {col.label}
                  </Table.Th>
                ))}
                {(onEdit || onDelete) && (
                  <Table.Th ta="right" fz="sm" fw={500} c="dimmed" px="md" py="sm">Actions</Table.Th>
                )}
              </Table.Tr>
            </Table.Thead>
            <Table.Tbody>
              {data.length === 0 ? (
                <Table.Tr>
                  <Table.Td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)}>
                    <Center h={96}>
                      <Text c="dimmed">No items yet.</Text>
                    </Center>
                  </Table.Td>
                </Table.Tr>
              ) : (
                data.map((row, i) => (
                  <Table.Tr key={i}>
                    {columns.map((col) => (
                      <Table.Td key={String(col.key)} px="md" py="sm" style={{ verticalAlign: 'middle' }}>
                        {col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')}
                      </Table.Td>
                    ))}
                    {(onEdit || onDelete) && (
                      <Table.Td px="md" py="sm" ta="right">
                        <Group gap={4} justify="flex-end">
                          {onEdit && (
                            <ActionIcon variant="subtle" size="sm" onClick={() => onEdit(row)}>
                              <IconPencil size={14} />
                            </ActionIcon>
                          )}
                          {onDelete && (
                            <ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(row)}>
                              <IconTrash size={14} />
                            </ActionIcon>
                          )}
                        </Group>
                      </Table.Td>
                    )}
                  </Table.Tr>
                ))
              )}
            </Table.Tbody>
          </Table>
        </Paper>

        {/* Form fields definition (for agent use) */}
        <div style={{ display: 'none' }} data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
      </Stack>
    )
  }

  export type { CrudPageProps, CrudField }

params_schema:
  {"params":[{"name":"props","desc":"Configuración CRUD: título, datos, columnas de tabla, campos de formulario y callbacks para add/edit/delete"}],"output":"Componente ReactElement que renderiza página CRUD completa con tabla y botones de acción"}


id: dashboard_layout_ts_ui
name: dashboard_layout
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: dashboardLayout(props: DashboardLayoutProps): ReactElement
tags: ["dashboard","layout","grid","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/dashboard_layout.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 7ffc204c545b63a545ebfa79fa9f0b01865540fc85c7db5f77a18aa9085aaf3e
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera un grid responsive de dashboard a partir de un array de widgets con span configurable. 1-4 columnas con auto-responsive.

example:
  ```tsx
  dashboardLayout({
    columns: 4,
    widgets: [
      { id: 'revenue', title: 'Revenue', content: <KPICard label="Revenue" value="$12k" /> },
      { id: 'users', title: 'Users', content: <KPICard label="Users" value={1234} /> },
      { id: 'chart', title: 'Trends', span: 2, content: <LineChart data={data} xKey="month" yKey="value" /> },
      { id: 'table', span: 4, content: <DataTable columns={cols} data={rows} /> },
    ]
  })
  ```

notes:
  Factory pura — dado el mismo input siempre genera el mismo JSX. Un agente puede construir dashboards completos pasando widgets como configuración declarativa.

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

  interface DashboardWidget {
    id: string
    title?: string
    span?: 1 | 2 | 3 | 4
    rowSpan?: 1 | 2
    content: React.ReactNode
  }

  interface DashboardLayoutProps {
    widgets: DashboardWidget[]
    columns?: 1 | 2 | 3 | 4
    gap?: 'sm' | 'md' | 'lg'
    className?: string
  }

  const gapMap = { sm: 'xs', md: 'md', lg: 'lg' } as const

  export function dashboardLayout({
    widgets,
    columns = 4,
    gap = 'md',
  }: DashboardLayoutProps): React.ReactElement {
    return (
      <SimpleGrid
        cols={{ base: 1, md: Math.min(columns, 2), lg: columns }}
        spacing={gapMap[gap]}
      >
        {widgets.map((widget) => (
          <Paper
            key={widget.id}
            p="md"
            withBorder
            shadow="xs"
            radius="md"
            style={{
              gridColumn: widget.span && widget.span > 1 ? `span ${widget.span}` : undefined,
              gridRow: widget.rowSpan === 2 ? 'span 2' : undefined,
            }}
          >
            {widget.title && (
              <Text size="sm" fw={500} c="dimmed" mb="sm">{widget.title}</Text>
            )}
            {widget.content}
          </Paper>
        ))}
      </SimpleGrid>
    )
  }

  export type { DashboardWidget, DashboardLayoutProps }

params_schema:
  {"params":[{"name":"props","desc":"Configuración de layout: número de columnas y array de widgets con id, título, contenido y span"}],"output":"Componente ReactElement que renderiza grid responsive de dashboard con ancho adaptable por widget"}


id: data_table_ts_ui
name: data_table
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DataTable(props: DataTableProps): JSX.Element
tags: ["table","data","heatmap","dashboard","component","ui","format","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/data_table.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default","heatmap"]
content_hash: 270afe0531e114d8b85eca295994907d565c2091bb1bb85c723345e644aeb9df
description:
  Tabla de datos con sticky header, overflow scroll, heatmap por columna, formato condicional (number/datetime/currency) y hover rows. Auto-detecta columnas desde la primera fila si no se proveen.

example:
  ```tsx
  // Tabla simple con auto-detección de columnas
  <DataTable data={rows} />

  // Con columnas definidas y heatmap
  <DataTable
    data={metrics}
    columns={[
      { key: 'domain', label: 'Domain' },
      { key: 'count', label: 'Functions', format: ',' },
      { key: 'pure_pct', label: 'Pure %', format: '.1f' },
    ]}
    heatmapColumns={['count', 'pure_pct']}
  />

  // Con formato moneda y fecha
  <DataTable
    data={transactions}
    columns={[
      { key: 'date', label: 'Date', format: 'datetime' },
      { key: 'amount', label: 'Amount', format: '$,.2f', align: 'right' },
      { key: 'description', label: 'Description' },
    ]}
  />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Filas de datos. Cada objeto es una fila."},{"name":"columns","type":"ColumnDef[]","required":false,"description":"Definición de columnas con key, label, format y align. Si se omite, se auto-detectan desde la primera fila."},{"name":"heatmapColumns","type":"string[]","required":false,"description":"Keys de columnas numéricas que deben colorearse por intensidad (azul oscuro=bajo, azul claro=alto)."},{"name":"maxHeight","type":"number | string","required":false,"description":"Altura máxima antes de scroll. Default 500px."},{"name":"loading","type":"boolean","required":false,"description":"Estado de carga. Muestra spinner si data está vacía."},{"name":"error","type":"Error | null","required":false,"description":"Error a mostrar si la carga falló."}]

notes:
  Extraido y generalizado desde `apps/rapid_dashboards/frontend/src/components/widgets/TableWidget.tsx`. El heatmap usa `useMemo` para calcular min/max por columna solo cuando cambian `data` o `heatmapColumns`. La alineación de celdas numéricas es automática (derecha) cuando el valor es `typeof 'number'`; se puede sobreescribir con el campo `align` en ColumnDef.

documentation:
  ## Formatos soportados (campo `format` en ColumnDef)

  | format | Ejemplo input | Output |
  |--------|--------------|--------|
  | `','` | `1234567` | `1,234,567` |
  | `',.2f'` | `1234.5` | `1,234.50` |
  | `'$,.2f'` | `1234.5` | `$1,234.50` |
  | `'.0f'` | `42.7` | `43` |
  | `'datetime'` | `'2026-04-01T12:00:00Z'` | `4/1/2026, 12:00:00 PM` |

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

  interface ColumnDef {
    key: string
    label: string
    /** Format string: ',.2f' | '$,.2f' | 'datetime' | ',' */
    format?: string
    /** Alignment override. Numbers default to right, strings to left. */
    align?: 'left' | 'right' | 'center'
  }

  interface DataTableProps {
    data: Record<string, unknown>[]
    columns?: ColumnDef[]
    /** Column keys that should be colored by value intensity (heatmap). */
    heatmapColumns?: string[]
    maxHeight?: number | string
    loading?: boolean
    error?: Error | null
  }

  function formatCell(value: unknown, format?: string): string {
    if (value == null) return '—'
    if (!format) return String(value)

    if (format === 'datetime' && !isNaN(Date.parse(String(value)))) {
      return new Date(String(value)).toLocaleString()
    }

    const num = Number(value)
    if (!isNaN(num)) {
      if (format.includes('f')) {
        const match = format.match(/\.(\d+)f/)
        const d = match ? parseInt(match[1]!) : 0
        let str = num.toFixed(d)
        if (format.includes(',')) {
          str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
        }
        if (format.startsWith('$')) str = '$' + str
        return str
      }
      if (format === ',') return num.toLocaleString()
    }
    return String(value)
  }

  function DataTableComponent({
    data,
    columns,
    heatmapColumns = [],
    maxHeight = 500,
    loading = false,
    error = null,
  }: DataTableProps) {
    // Auto-detect columns from first row if not provided
    const effectiveColumns: ColumnDef[] = (columns && columns.length > 0)
      ? columns
      : (data && data.length > 0)
        ? Object.keys(data[0]!).map(k => ({ key: k, label: k }))
        : []

    // Compute heatmap ranges per column
    const heatmapRanges = React.useMemo(() => {
      const ranges: Record<string, { min: number; max: number }> = {}
      if (heatmapColumns.length > 0 && data && data.length > 0) {
        for (const key of heatmapColumns) {
          const values = data.map(r => Number(r[key])).filter(n => !isNaN(n))
          if (values.length > 0) {
            ranges[key] = { min: Math.min(...values), max: Math.max(...values) }
          }
        }
      }
      return ranges
    }, [data, heatmapColumns])

    function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined {
      const range = heatmapRanges[key]
      if (!range || range.max === range.min) return undefined
      const num = Number(value)
      if (isNaN(num)) return undefined
      const t = (num - range.min) / (range.max - range.min)
      const alpha = 0.1 + t * 0.55
      return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
    }

    if (loading && (!data || data.length === 0)) {
      return (
        <Center h={200}>
          <Loader size="sm" />
        </Center>
      )
    }

    if (error) {
      return (
        <Center h={200}>
          <Text size="sm" c="red">{error.message}</Text>
        </Center>
      )
    }

    return (
      <Table.ScrollContainer minWidth={0} mah={maxHeight} type="scrollarea">
        <Table striped={false} highlightOnHover withTableBorder={false} withColumnBorders={false}>
          <Table.Thead style={{ position: 'sticky', top: 0, zIndex: 10, backgroundColor: 'var(--mantine-color-body)' }}>
            <Table.Tr>
              {effectiveColumns.map(col => (
                <Table.Th
                  key={col.key}
                  style={{ whiteSpace: 'nowrap' }}
                  fz="xs"
                  fw={500}
                  c="dimmed"
                  tt="uppercase"
                  py={6}
                  px="sm"
                >
                  {col.label}
                </Table.Th>
              ))}
            </Table.Tr>
          </Table.Thead>
          <Table.Tbody>
            {(data ?? []).map((row, i) => (
              <Table.Tr key={i}>
                {effectiveColumns.map(col => {
                  const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left')
                  return (
                    <Table.Td
                      key={col.key}
                      style={{ textAlign: align, fontFamily: 'var(--mantine-font-family-monospace)', ...heatmapStyle(col.key, row[col.key]) }}
                      fz="xs"
                      py={6}
                      px="sm"
                    >
                      {formatCell(row[col.key], col.format)}
                    </Table.Td>
                  )
                })}
              </Table.Tr>
            ))}
          </Table.Tbody>
        </Table>
        {(!data || data.length === 0) && (
          <Center py="xl">
            <Text size="sm" c="dimmed">No data</Text>
          </Center>
        )}
      </Table.ScrollContainer>
    )
  }

  export const DataTable = DataTableComponent
  export type { DataTableProps, ColumnDef }

params_schema:
  {"output":"Componente DataTable que renderiza tabla con sticky header, heatmap condicional y formato automático de datos"}


id: date_picker_input_ts_ui
name: date_picker_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DatePickerInput(props: DatePickerInputProps): JSX.Element
tags: ["date","picker","calendar","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/dates"]
tests: []
file_path: frontend/functions/ui/date_picker_input.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["default"]
content_hash: 6be456f1545db0a75befbf40c041bae937ba1a860363fd51c0f7aed18f08cd7a
description:
  Selector de fecha con input y calendario desplegable. Soporta fecha simple, múltiple y rango. Wrapper sobre Mantine DatePickerInput.

example:
  ```tsx
  import { DatePickerInput, DatePicker } from '@fn_library'
  import { useState } from 'react'

  // Fecha simple
  function SingleDateExample() {
    const [value, setValue] = useState<Date | null>(null)
    return (
      <DatePickerInput
        label="Fecha de inicio"
        placeholder="Selecciona una fecha"
        value={value}
        onChange={setValue}
        clearable
      />
    )
  }

  // Rango de fechas
  function RangeDateExample() {
    const [range, setRange] = useState<[Date | null, Date | null]>([null, null])
    return (
      <DatePickerInput
        type="range"
        label="Periodo"
        placeholder="Selecciona un rango"
        value={range}
        onChange={setRange}
        valueFormat="DD/MM/YYYY"
      />
    )
  }

  // Múltiples fechas
  function MultipleDateExample() {
    const [dates, setDates] = useState<Date[]>([])
    return (
      <DatePickerInput
        type="multiple"
        label="Días seleccionados"
        placeholder="Selecciona fechas"
        value={dates}
        onChange={setDates}
        minDate={new Date()}
      />
    )
  }

  // DatePicker inline (sin input)
  function InlineDateExample() {
    const [value, setValue] = useState<Date | null>(null)
    return (
      <DatePicker
        value={value}
        onChange={setValue}
      />
    )
  }
  ```

props:
  [{"name":"type","type":"'default' | 'multiple' | 'range'","required":false,"description":"Modo de selección — fecha simple, múltiples fechas, o rango de fechas"},{"name":"value","type":"DateValue | DateValue[] | [DateValue, DateValue] | null","required":false,"description":"Fecha o fechas seleccionadas (controlled)"},{"name":"onChange","type":"(value: DateValue | DateValue[] | [DateValue, DateValue] | null) =\u003e void","required":false,"description":"Callback al cambiar la selección de fecha"},{"name":"valueFormat","type":"string","required":false,"description":"Formato de la fecha mostrada en el input (ej: 'DD/MM/YYYY')"},{"name":"clearable","type":"boolean","required":false,"description":"Permite limpiar la selección de fecha"},{"name":"label","type":"string","required":false,"description":"Label del campo"},{"name":"placeholder","type":"string","required":false,"description":"Texto cuando no hay fecha seleccionada"},{"name":"minDate","type":"Date","required":false,"description":"Fecha mínima seleccionable"},{"name":"maxDate","type":"Date","required":false,"description":"Fecha máxima seleccionable"},{"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 `DatePickerInput` y `DatePicker` de `@mantine/dates` v9. Todas las props de Mantine son válidas.
  - Requiere importar `@mantine/dates/styles.css` — este wrapper ya lo incluye.
  - El prop `type` controla el modo: `'default'` (fecha simple), `'multiple'` (varias fechas), `'range'` (rango con inicio y fin).
  - `DatePicker` es el calendario inline sin input — útil para formularios donde el calendario debe estar siempre visible.
  - `valueFormat` acepta tokens de dayjs (ej: `'DD/MM/YYYY'`, `'MMMM D, YYYY'`).
  - Re-exporta también `DatePicker` de `@mantine/dates` con el mismo patrón de wrapper.

code:
  import { DatePickerInput as MantineDatePickerInput, DatePicker as MantineDatePicker } from '@mantine/dates'
  import type { DatePickerInputProps as MantineDatePickerInputProps, DatePickerProps as MantineDatePickerProps } from '@mantine/dates'
  import '@mantine/dates/styles.css'

  interface DatePickerInputProps extends MantineDatePickerInputProps<'default'> {}
  interface DatePickerProps extends MantineDatePickerProps<'default'> {}

  function DatePickerInput(props: DatePickerInputProps) {
    return <MantineDatePickerInput {...props} />
  }

  function DatePicker(props: DatePickerProps) {
    return <MantineDatePicker {...props} />
  }

  export { DatePickerInput, DatePicker }
  export type { DatePickerInputProps, DatePickerProps }

params_schema:
  {"params":[{"name":"props","desc":"Props del componente DatePickerInput — incluye type (modo de selección), value, onChange, valueFormat, clearable, label, placeholder, minDate, maxDate, disabled y size"}],"output":"Componente DatePickerInput que renderiza input con calendario para selección de fechas"}


id: detail_page_ts_ui
name: detail_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: detailPage(props: DetailPageProps): ReactElement
tags: ["detail","page","entity","timeline","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/detail_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 413d5548fbc3eb2711692b43ac67c28af8c912713edb02e7d17617e7114b0fbe
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera una página de detalle de entidad con header (avatar, badge, back), grid de campos, tabs con contadores y timeline de actividad.

example:
  ```tsx
  detailPage({
    title: 'John Doe',
    subtitle: 'john@example.com',
    badge: <Badge variant="success">Active</Badge>,
    onBack: () => router.back(),
    fields: [
      { label: 'Role', value: 'Administrator' },
      { label: 'Created', value: 'Mar 15, 2026' },
      { label: 'Bio', value: 'Full stack developer...', span: 2 },
    ],
    tabs: [
      { label: 'Projects', value: 'projects', count: 12, content: <ProjectList /> },
      { label: 'Activity', value: 'activity', count: 48, content: <ActivityList /> },
    ],
    activeTab: 'projects',
    timeline: [
      { id: '1', title: 'Deployed v2.1', timestamp: '2 hours ago', variant: 'success' },
      { id: '2', title: 'Updated settings', timestamp: 'Yesterday' },
      { id: '3', title: 'Created project', timestamp: 'Mar 10, 2026' },
    ],
  })
  ```

notes:
  Factory completa para páginas de detalle. Combina header con back/avatar/badge, grid de metadata, tabs con badges de conteo, y timeline de actividad con variantes de color semántico.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, ActionIcon, Box, Tabs, Badge, Timeline, SimpleGrid } from '@mantine/core'
  import { IconChevronLeft } from '@tabler/icons-react'

  interface DetailField {
    label: string
    value: React.ReactNode
    span?: 1 | 2
  }

  interface DetailTab {
    label: string
    value: string
    content: React.ReactNode
    count?: number
  }

  interface TimelineEvent {
    id: string
    title: string
    description?: string
    timestamp: string
    icon?: React.ReactNode
    variant?: 'default' | 'success' | 'warning' | 'error'
  }

  interface DetailPageProps {
    title: string
    subtitle?: string
    badge?: React.ReactNode
    avatar?: React.ReactNode
    actions?: React.ReactNode
    onBack?: () => void
    fields: DetailField[]
    tabs?: DetailTab[]
    activeTab?: string
    onTabChange?: (value: string) => void
    timeline?: TimelineEvent[]
    className?: string
  }

  const variantColors: Record<string, string> = {
    default: 'blue',
    success: 'green',
    warning: 'yellow',
    error: 'red',
  }

  export function detailPage({
    title, subtitle, badge, avatar, actions, onBack,
    fields, tabs, activeTab, onTabChange, timeline,
  }: DetailPageProps): React.ReactElement {
    return (
      <Stack gap="lg">
        {/* Header */}
        <Group justify="space-between" align="flex-start" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Group align="flex-start" gap="md">
            {onBack && (
              <ActionIcon variant="subtle" size="sm" onClick={onBack} mt={4}>
                <IconChevronLeft size={16} />
              </ActionIcon>
            )}
            {avatar && (
              <Box
                w={48}
                h={48}
                style={{ flexShrink: 0, overflow: 'hidden', borderRadius: '50%', backgroundColor: 'var(--mantine-color-default)' }}
              >
                {avatar}
              </Box>
            )}
            <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">{actions}</Group>}
        </Group>

        {/* Fields grid */}
        <SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
          {fields.map((field, i) => (
            <Box key={i} style={field.span === 2 ? { gridColumn: 'span 2' } : undefined}>
              <Stack gap={4}>
                <Text size="sm" c="dimmed">{field.label}</Text>
                <Text size="sm" fw={500}>{field.value}</Text>
              </Stack>
            </Box>
          ))}
        </SimpleGrid>

        {/* Tabs */}
        {tabs && tabs.length > 0 && (
          <Stack gap="md">
            <Tabs value={activeTab} onChange={(v) => v && onTabChange?.(v)}>
              <Tabs.List>
                {tabs.map((tab) => (
                  <Tabs.Tab
                    key={tab.value}
                    value={tab.value}
                    rightSection={tab.count !== undefined ? <Badge size="xs" variant="filled" circle>{tab.count}</Badge> : undefined}
                  >
                    {tab.label}
                  </Tabs.Tab>
                ))}
              </Tabs.List>
            </Tabs>
            {tabs.find(t => t.value === activeTab)?.content}
          </Stack>
        )}

        {/* Timeline */}
        {timeline && timeline.length > 0 && (
          <Stack gap="sm">
            <Text size="sm" fw={500} c="dimmed">Activity</Text>
            <Timeline active={timeline.length - 1} bulletSize={12} lineWidth={2}>
              {timeline.map((event) => (
                <Timeline.Item
                  key={event.id}
                  color={variantColors[event.variant || 'default']}
                  title={<Text size="sm" fw={500}>{event.title}</Text>}
                >
                  {event.description && <Text size="xs" c="dimmed">{event.description}</Text>}
                  <Text size="xs" c="dimmed" opacity={0.7}>{event.timestamp}</Text>
                </Timeline.Item>
              ))}
            </Timeline>
          </Stack>
        )}
      </Stack>
    )
  }

  export type { DetailPageProps, DetailField, DetailTab, TimelineEvent }

params_schema:
  {"params":[{"name":"props","desc":"Configuración de página de detalle: título, avatar, badge, tabs, timeline y campos de metadata"}],"output":"Componente ReactElement que renderiza página de detalle con header, grid de campos y timeline de actividad"}


id: dialog_ts_ui
name: dialog
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Dialog(props: DialogRootProps): JSX.Element
tags: ["dialog","modal","overlay","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/dialog.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onOpenChange"]
framework: react
variant: ["default"]
content_hash: 8dd9f2cfdd8a9a435a7491a15f50f5156403ad77ec0213d196366f5c429d7444
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/dialog.tsx
description:
  Diálogo modal accesible con close button y sistema de slots (header, footer, title, description). Mantine Modal.

example:
  ```tsx
  <Dialog>
    <DialogTrigger asChild><Button>Open</Button></DialogTrigger>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Título</DialogTitle>
        <DialogDescription>Descripción</DialogDescription>
      </DialogHeader>
      <p>Contenido</p>
      <DialogFooter>
        <Button>Confirmar</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
  ```

props:
  [{"name":"showCloseButton","type":"boolean","required":false,"description":"Mostrar botón de cerrar (default true)"}]

notes:
  10 subcomponentes exportados. Mantine Modal para accesibilidad completa (focus trap, escape, click outside). DialogPortal y DialogOverlay son no-ops mantenidos por compatibilidad.

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

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

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

  function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
    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 (
      <DialogContext.Provider value={{ open, setOpen }}>
        {children}
      </DialogContext.Provider>
    )
  }

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

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

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

  function DialogOverlay() {
    return null
  }

  function DialogContent({
    children,
    showCloseButton = true,
    className,
    ...props
  }: React.ComponentProps<'div'> & { showCloseButton?: boolean }) {
    const { open, setOpen } = React.useContext(DialogContext)
    return (
      <Modal
        opened={open}
        onClose={() => setOpen(false)}
        withCloseButton={showCloseButton}
        radius="md"
        padding="md"
        size="sm"
        centered
        data-slot="dialog-content"
        className={className}
        {...props}
      >
        {children}
      </Modal>
    )
  }

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

  function DialogFooter({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Group
        data-slot="dialog-footer"
        justify="flex-end"
        gap="sm"
        mt="md"
        pt="sm"
        style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
        className={className}
        {...props}
      >
        {children}
      </Group>
    )
  }

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

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

  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }

params_schema:
  {"output":"Componente Dialog que renderiza modal accesible con focus trap y sistema de slots composables via Mantine Modal"}


id: dropdown_menu_ts_ui
name: dropdown_menu
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DropdownMenu(props: DropdownMenuProps): JSX.Element
tags: ["dropdown","menu","component","ui","interactive","overlay","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/dropdown_menu.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onOpenChange"]
framework: react
variant: []
content_hash: 208935252cc7c63176607e43d2c89f566f0ecf17494e2475fb1d8fd55dd94134
description:
  Menu de acciones y contexto accesible con items, checkboxes, radios, separadores y submenus. Base-UI Menu primitive.

example:
  ```tsx
  <DropdownMenu>
    <DropdownMenuTrigger asChild>
      <Button variant="outline">Acciones</Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent>
      <DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
      <DropdownMenuSeparator />
      <DropdownMenuItem onActivate={() => console.log("Perfil")}>
        Perfil
      </DropdownMenuItem>
      <DropdownMenuCheckboxItem checked={showBookmarks} onCheckedChange={setShowBookmarks}>
        Marcadores
      </DropdownMenuCheckboxItem>
      <DropdownMenuSeparator />
      <DropdownMenuSub>
        <DropdownMenuSubTrigger>Mas opciones</DropdownMenuSubTrigger>
        <DropdownMenuSubContent>
          <DropdownMenuItem>Opcion A</DropdownMenuItem>
        </DropdownMenuSubContent>
      </DropdownMenuSub>
    </DropdownMenuContent>
  </DropdownMenu>
  ```

props:
  [{"name":"open","type":"boolean","required":false,"description":"Estado controlado de apertura"},{"name":"defaultOpen","type":"boolean","required":false,"description":"Estado inicial de apertura"},{"name":"onOpenChange","type":"(open: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"},{"name":"modal","type":"boolean","required":false,"description":"Comportamiento modal (default: true)"}]

notes:
  Exports: DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal.

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

  function DropdownMenu({ children, ...props }: { children: React.ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; modal?: boolean }) {
    return (
      <Menu
        opened={props.open}
        defaultOpened={props.defaultOpen}
        onChange={props.onOpenChange}
        withinPortal
        shadow="md"
      >
        {children}
      </Menu>
    )
  }

  function DropdownMenuTrigger({ children, ...props }: { children: React.ReactNode; asChild?: boolean; className?: string }) {
    return <Menu.Target {...props}>{children}</Menu.Target>
  }

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

  function DropdownMenuContent({ children, className }: { children?: React.ReactNode; className?: string; sideOffset?: number }) {
    return <Menu.Dropdown className={className}>{children}</Menu.Dropdown>
  }

  function DropdownMenuItem({ children, className, inset, ...props }: {
    children?: React.ReactNode
    className?: string
    inset?: boolean
    onClick?: () => void
    onActivate?: () => void
    disabled?: boolean
  }) {
    return (
      <Menu.Item
        className={className}
        onClick={props.onClick ?? props.onActivate}
        disabled={props.disabled}
        pl={inset ? 'xl' : undefined}
      >
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuCheckboxItem({ children, className, checked, onCheckedChange, ...props }: {
    children?: React.ReactNode
    className?: string
    checked?: boolean
    onCheckedChange?: (checked: boolean) => void
    disabled?: boolean
  }) {
    return (
      <Menu.Item
        className={className}
        onClick={() => onCheckedChange?.(!checked)}
        disabled={props.disabled}
        leftSection={checked ? <span style={{ fontSize: 14 }}>✓</span> : <span style={{ width: 14 }} />}
      >
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuRadioGroup({ children }: { children?: React.ReactNode; value?: string; onValueChange?: (value: string) => void }) {
    return <>{children}</>
  }

  function DropdownMenuRadioItem({ children, className, value, ...props }: {
    children?: React.ReactNode
    className?: string
    value?: string
    disabled?: boolean
    onClick?: () => void
  }) {
    return (
      <Menu.Item className={className} onClick={props.onClick} disabled={props.disabled}>
        {children}
      </Menu.Item>
    )
  }

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

  function DropdownMenuLabel({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) {
    return (
      <Menu.Label className={className} pl={inset ? 'xl' : undefined}>
        {children}
      </Menu.Label>
    )
  }

  function DropdownMenuSeparator({ className }: { className?: string }) {
    return <Menu.Divider className={className} />
  }

  function DropdownMenuShortcut({ children, className }: { children?: React.ReactNode; className?: string }) {
    return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
  }

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

  function DropdownMenuSubTrigger({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) {
    return (
      <Menu.Item className={className} pl={inset ? 'xl' : undefined}>
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuSubContent({ children, className }: { children?: React.ReactNode; className?: string }) {
    return <Menu.Dropdown className={className}>{children}</Menu.Dropdown>
  }

  export {
    DropdownMenu,
    DropdownMenuCheckboxItem,
    DropdownMenuContent,
    DropdownMenuGroup,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuPortal,
    DropdownMenuRadioGroup,
    DropdownMenuRadioItem,
    DropdownMenuSeparator,
    DropdownMenuShortcut,
    DropdownMenuSub,
    DropdownMenuSubContent,
    DropdownMenuSubTrigger,
    DropdownMenuTrigger,
  }

params_schema:
  {"output":"Componente DropdownMenu que renderiza menú desplegable accesible con items, checkboxes, radios y submenus"}


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: bq_auth_py_infra
name: bq_auth
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_auth(project_id: str = '', credentials_path: str = '') -> BQClient
tags: ["bigquery","gcp","auth","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/client.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: ea652c31b5a87d8d48926920f84dc409e492ea0c59f9e16f0569c25685f5580b
description:
  Autentica contra Google BigQuery con ADC o service account JSON. Retorna un BQClient listo para usar con todas las funciones CRUD.

example:
  ```python
  from bigquery import bq_auth

  # ADC (gcloud auth application-default login)
  client = bq_auth()

  # Proyecto explicito
  client = bq_auth("my-project-id")

  # Service account
  client = bq_auth(credentials_path="/path/to/service-account.json")

  # Context manager
  with bq_auth() as client:
      # client se cierra automaticamente
      pass
  ```

notes:
  Tres modos de autenticacion:
  - Sin argumentos: usa Application Default Credentials (ADC) — requiere `gcloud auth application-default login`
  - Con project_id: usa ADC pero fuerza el proyecto
  - Con credentials_path: lee el JSON de service account directamente

  El BQClient wrappea `google.cloud.bigquery.Client` y expone `_client` para que las funciones del modulo lo usen internamente.

code:
  """Cliente base para Google BigQuery."""

  from dataclasses import dataclass, field
  from google.cloud import bigquery
  from google.oauth2 import service_account


  @dataclass
  class BQClient:
      """Cliente para Google BigQuery.

      Attributes:
          project_id: ID del proyecto GCP.
          _client: Cliente oficial de BigQuery (interno).
      """
      project_id: str
      _client: bigquery.Client = field(repr=False)

      def close(self) -> None:
          """Cierra el cliente BigQuery."""
          self._client.close()

      def __enter__(self):
          return self

      def __exit__(self, *args):
          self.close()


  def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient:
      """Autentica contra Google BigQuery.

      Tres modos de autenticacion:
      1. ADC (Application Default Credentials): sin argumentos, usa gcloud auth
      2. Service account JSON: con credentials_path
      3. Proyecto explicito: con project_id (usa ADC para credenciales)

      Args:
          project_id: ID del proyecto GCP. Vacio = detectar de credenciales.
          credentials_path: Ruta a archivo JSON de service account. Vacio = ADC.

      Returns:
          BQClient autenticado listo para usar.

      Raises:
          google.auth.exceptions.DefaultCredentialsError: Si no hay credenciales configuradas.
          FileNotFoundError: Si credentials_path no existe.

      Example:
          >>> client = bq_auth()  # ADC
          >>> client = bq_auth("my-project")  # ADC con proyecto explicito
          >>> client = bq_auth(credentials_path="/path/to/sa.json")  # Service account
      """
      if credentials_path:
          creds = service_account.Credentials.from_service_account_file(credentials_path)
          proj = project_id or creds.project_id
          client = bigquery.Client(credentials=creds, project=proj)
      elif project_id:
          client = bigquery.Client(project=project_id)
      else:
          client = bigquery.Client()

      return BQClient(project_id=client.project, _client=client)

params_schema:
  {"params":[{"name":"project_id","desc":"ID del proyecto GCP (vacio = detectar de credenciales/entorno)"},{"name":"credentials_path","desc":"ruta a archivo JSON de service account (vacio = Application Default Credentials)"}],"output":"BQClient: cliente autenticado con proyecto resuelto"}


id: bq_cancel_job_py_infra
name: bq_cancel_job
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_cancel_job(client: BQClient, job_id: str) -> dict
description: Cancela un job en ejecucion. Retorna el estado tras la cancelacion.
tags: ["bigquery","gcp","job","cancel","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/jobs.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: f5f1c8b57f5ad7b83c5647722e2328bd56e2d8acb6d569eb001fb5c32fb50d71
example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.jobs import bq_list_jobs, bq_cancel_job

  client = bq_auth("my-project")

  # Cancelar todos los jobs en ejecucion
  running = bq_list_jobs(client, state_filter="running")
  for job in running:
      result = bq_cancel_job(client, job["job_id"])
      print(f"Cancelado {result['job_id']}: {result['state']}")
  ```

notes:
  Lanza `google.api_core.exceptions.NotFound` si el job_id no existe en el proyecto.

  BigQuery no garantiza cancelacion inmediata: el job puede seguir procesando brevemente antes de detenerse. El estado retornado refleja el estado al momento de la respuesta de la API, que tipicamente es `"DONE"`.

  Cancelar un job ya completado o ya cancelado no genera error; la API lo acepta de forma idempotente y retorna el estado actual.

  Solo el usuario que creo el job o un administrador del proyecto puede cancelarlo.

code:
  """Gestion de jobs en Google BigQuery."""

  from .client import BQClient


  def bq_list_jobs(
      client: BQClient,
      state_filter: str = "",
      max_results: int = 50,
      all_users: bool = False,
  ) -> list[dict]:
      """Lista jobs del proyecto con filtro opcional por estado.

      Args:
          client: Cliente autenticado.
          state_filter: Filtro: "running", "pending", "done". Vacio = todos.
          max_results: Numero maximo de jobs a retornar.
          all_users: Si True, lista jobs de todos los usuarios (requiere permisos).

      Returns:
          Lista de dicts con: job_id, job_type, state, created, started, ended,
          user_email, bytes_processed, error.

      Example:
          >>> jobs = bq_list_jobs(client, state_filter="running")
          >>> for j in jobs:
          ...     print(j["job_id"], j["state"], j["job_type"])
      """
      kwargs = {"max_results": max_results, "all_users": all_users}
      if state_filter:
          kwargs["state_filter"] = state_filter
      return [_job_to_dict(job) for job in client._client.list_jobs(**kwargs)]


  def bq_get_job(client: BQClient, job_id: str) -> dict:
      """Obtiene detalles de un job por su ID.

      Args:
          client: Cliente autenticado.
          job_id: ID del job.

      Returns:
          Dict con: job_id, job_type, state, created, started, ended,
          user_email, bytes_processed, destination_table, query, error, errors.

      Raises:
          google.api_core.exceptions.NotFound: Si el job no existe.

      Example:
          >>> job = bq_get_job(client, "job_abc123")
          >>> print(job["state"], job["bytes_processed"])
          >>> if job["error"]:
          ...     print("Error:", job["error"])
      """
      job = client._client.get_job(job_id)
      result = _job_to_dict(job)
      # Agregar detalles extra disponibles en get pero no en list
      if hasattr(job, 'destination') and job.destination:
          result["destination_table"] = str(job.destination)
      if hasattr(job, 'query') and job.query:
          result["query"] = job.query
      if job.errors:
          result["errors"] = [dict(e) for e in job.errors]
      return result


  def bq_cancel_job(client: BQClient, job_id: str) -> dict:
      """Cancela un job en ejecucion.

      Args:
          client: Cliente autenticado.
          job_id: ID del job a cancelar.

      Returns:
          Dict con: job_id, state (tras cancelacion).

      Raises:
          google.api_core.exceptions.NotFound: Si el job no existe.

      Example:
          >>> result = bq_cancel_job(client, "job_abc123")
          >>> print(result["state"])  # "DONE" (cancelled)
      """
      job = client._client.cancel_job(job_id)
      return {"job_id": job.job_id, "state": job.state}


  def _job_to_dict(job) -> dict:
      """Convierte un objeto Job del SDK a dict plano."""
      result = {
          "job_id": job.job_id,
          "job_type": job.job_type,
          "state": job.state,
          "created": job.created.isoformat() if job.created else None,
          "started": job.started.isoformat() if job.started else None,
          "ended": job.ended.isoformat() if job.ended else None,
          "user_email": job.user_email,
          "error": str(job.error_result) if job.error_result else None,
      }
      # bytes_processed solo disponible en query jobs
      try:
          result["bytes_processed"] = job.total_bytes_processed
      except AttributeError:
          result["bytes_processed"] = None
      return result

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado de BigQuery obtenido con bq_auth"},{"name":"job_id","desc":"ID del job a cancelar (formato: 'proyecto:region.job_id' o solo 'job_id')"}],"output":"dict con campos: job_id (string), state (string, tipicamente 'DONE' tras la cancelacion)"}


id: bq_copy_table_py_infra
name: bq_copy_table
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","copy","table","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/queries.py
created_at: 2026-04-07T09:57:08Z
updated_at: 2026-04-07T09:57:08Z
props: []
emits: []
variant: []
content_hash: 615ce86ddeb90633de7475e1f8f0c0d983213aa0baa718da20d07cb2c8b46360
signature:
  def bq_copy_table(client: BQClient, source_dataset: str, source_table: str, dest_dataset: str, dest_table: str, write_disposition: str = 'WRITE_EMPTY') -> dict

description:
  Copia una tabla BigQuery a otro dataset o tabla dentro del mismo proyecto usando copy_table del SDK. Espera la finalizacion del CopyJob.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.queries import bq_copy_table

  client = bq_auth("my-project")

  # Copia de produccion a staging (falla si ya existe)
  result = bq_copy_table(
      client,
      "production", "users",
      "staging",   "users_backup",
  )
  print(f"Copia completada: {result['status']} — job: {result['job_id']}")

  # Copia sobreescribiendo destino
  result = bq_copy_table(
      client,
      "raw", "events_2024",
      "processed", "events_latest",
      write_disposition="WRITE_TRUNCATE",
  )
  ```

notes:
  `copy_table` solo funciona dentro del mismo proyecto GCP. Para copiar entre proyectos,
  usar `bq_export_to_gcs` + `bq_load_from_gcs`.

  Si la tabla destino no existe, BigQuery la crea con el schema de la tabla origen
  independientemente del `write_disposition`.

  El CopyJob es asincrono; `job.result()` bloquea hasta completar. La copia no mueve
  datos fisicamente — BigQuery usa referencias de bloques internamente hasta que se
  modifica la copia.

code:
  """Queries y operaciones de datos para Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_query(
      client: BQClient,
      sql: str,
      params: list[dict] | None = None,
      dry_run: bool = False,
  ) -> dict:
      """Ejecuta una query SQL en BigQuery con soporte para parametros y dry-run.

      Si dry_run=True, estima el costo sin ejecutar la query. Si params no es
      None, los convierte a ScalarQueryParameter usando la sintaxis @nombre en SQL.

      Args:
          client: Cliente autenticado de BigQuery.
          sql: Query SQL a ejecutar. Usar @nombre para referencias a parametros.
          params: Lista de parametros. Cada elemento: {"name": "x", "type": "STRING", "value": "hello"}.
                  Tipos soportados: STRING, INT64, FLOAT64, BOOL, DATE, TIMESTAMP.
          dry_run: Si True, retorna estimacion de bytes sin ejecutar.

      Returns:
          Si dry_run=True: {"total_bytes_processed": N, "total_bytes_billed": N}.
          Si dry_run=False: {"columns": [...], "rows": [[...], ...],
                             "total_rows": N, "bytes_processed": N, "cache_hit": bool}.

      Raises:
          google.api_core.exceptions.GoogleAPICallError: Si la query falla.
          ValueError: Si un tipo de parametro no es soportado.

      Example:
          >>> result = bq_query(client, "SELECT COUNT(*) as n FROM `project.dataset.table`")
          >>> print(result["rows"])
          >>> # Con parametros:
          >>> result = bq_query(client, "SELECT * FROM t WHERE status = @s",
          ...     params=[{"name": "s", "type": "STRING", "value": "active"}])
      """
      job_config = bigquery.QueryJobConfig()

      if params:
          query_params = []
          for p in params:
              query_params.append(
                  bigquery.ScalarQueryParameter(p["name"], p["type"], p["value"])
              )
          job_config.query_parameters = query_params

      if dry_run:
          job_config.dry_run = True
          job_config.use_query_cache = False
          job = client._client.query(sql, job_config=job_config)
          return {
              "total_bytes_processed": job.total_bytes_processed,
              "total_bytes_billed": job.total_bytes_billed,
          }

      job = client._client.query(sql, job_config=job_config)
      result = job.result()

      columns = [field.name for field in result.schema]
      rows = [list(row.values()) for row in result]

      return {
          "columns": columns,
          "rows": rows,
          "total_rows": result.total_rows,
          "bytes_processed": job.total_bytes_processed,
          "cache_hit": job.cache_hit,
      }


  def bq_insert_rows(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      rows: list[dict],
  ) -> dict:
      """Inserta filas en una tabla BigQuery usando el streaming insert.

      Usa la API de streaming (insert_rows_json) para insercion en tiempo real.
      Los errores de filas individuales se retornan en el campo "errors" sin
      lanzar excepcion — revisar siempre ese campo.

      Args:
          client: Cliente autenticado de BigQuery.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          rows: Lista de dicts con los datos a insertar. Cada dict debe tener
                claves que coincidan con las columnas de la tabla.

      Returns:
          {"inserted": N, "errors": [...]} donde errors es la lista de errores
          de streaming insert retornada por la API (vacia si todo OK).

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.GoogleAPICallError: Si la API falla.

      Example:
          >>> result = bq_insert_rows(client, "my_dataset", "my_table", [
          ...     {"id": 1, "name": "Alice", "created_at": "2024-01-01"},
          ...     {"id": 2, "name": "Bob",   "created_at": "2024-01-02"},
          ... ])
          >>> if result["errors"]:
          ...     print("Errores:", result["errors"])
      """
      table_ref = client._client.dataset(dataset_id).table(table_id)
      errors = client._client.insert_rows_json(table_ref, rows)
      return {
          "inserted": len(rows) - len(errors),
          "errors": errors,
      }


  def bq_load_from_gcs(
      client: BQClient,
      uri: str | list[str],
      dataset_id: str,
      table_id: str,
      source_format: str = "CSV",
      write_disposition: str = "WRITE_APPEND",
      autodetect: bool = True,
      skip_leading_rows: int = 0,
  ) -> dict:
      """Carga datos desde Google Cloud Storage a una tabla BigQuery.

      Configura y ejecuta un LoadJob desde uno o varios URIs de GCS. Espera
      la finalizacion del job con job.result().

      Args:
          client: Cliente autenticado de BigQuery.
          uri: URI de GCS. Puede ser un string ("gs://bucket/file.csv") o una
               lista de strings para multiples archivos.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          source_format: Formato del archivo: "CSV", "NEWLINE_DELIMITED_JSON",
                         "AVRO", "PARQUET", "ORC". Default: "CSV".
          write_disposition: Comportamiento si la tabla existe: "WRITE_APPEND",
                             "WRITE_TRUNCATE", "WRITE_EMPTY". Default: "WRITE_APPEND".
          autodetect: Si True, detecta el schema automaticamente. Default: True.
          skip_leading_rows: Numero de filas a saltar al inicio (cabeceras CSV).

      Returns:
          {"job_id": str, "rows_loaded": N, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.
          google.cloud.exceptions.NotFound: Si el bucket o dataset no existe.

      Example:
          >>> result = bq_load_from_gcs(
          ...     client, "gs://my-bucket/data/*.csv",
          ...     "my_dataset", "my_table",
          ...     skip_leading_rows=1,
          ... )
          >>> print(f"Cargadas {result['rows_loaded']} filas, job: {result['job_id']}")
      """
      format_map = {
          "CSV": bigquery.SourceFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.SourceFormat.AVRO,
          "PARQUET": bigquery.SourceFormat.PARQUET,
          "ORC": bigquery.SourceFormat.ORC,
      }
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.LoadJobConfig(
          source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
          write_disposition=disposition_map.get(source_format, bigquery.WriteDisposition.WRITE_APPEND),
          autodetect=autodetect,
          skip_leading_rows=skip_leading_rows,
      )
      job_config.write_disposition = disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND)

      table_ref = client._client.dataset(dataset_id).table(table_id)
      uris = uri if isinstance(uri, list) else [uri]

      job = client._client.load_table_from_uri(uris, table_ref, job_config=job_config)
      result = job.result()

      return {
          "job_id": job.job_id,
          "rows_loaded": result.output_rows,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_load_from_file(
      client: BQClient,
      file_path: str,
      dataset_id: str,
      table_id: str,
      source_format: str = "CSV",
      write_disposition: str = "WRITE_APPEND",
      autodetect: bool = True,
      skip_leading_rows: int = 0,
  ) -> dict:
      """Carga datos desde un archivo local a una tabla BigQuery.

      Abre el archivo y usa load_table_from_file del SDK. Equivalente a
      bq_load_from_gcs pero para archivos en disco local.

      Args:
          client: Cliente autenticado de BigQuery.
          file_path: Ruta absoluta o relativa al archivo local a cargar.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          source_format: Formato del archivo: "CSV", "NEWLINE_DELIMITED_JSON",
                         "AVRO", "PARQUET", "ORC". Default: "CSV".
          write_disposition: Comportamiento si la tabla existe: "WRITE_APPEND",
                             "WRITE_TRUNCATE", "WRITE_EMPTY". Default: "WRITE_APPEND".
          autodetect: Si True, detecta el schema automaticamente. Default: True.
          skip_leading_rows: Numero de filas a saltar al inicio (cabeceras CSV).

      Returns:
          {"job_id": str, "rows_loaded": N, "status": "DONE"|"FAILED"}.

      Raises:
          FileNotFoundError: Si el archivo local no existe.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_load_from_file(
          ...     client, "/tmp/data.csv",
          ...     "my_dataset", "my_table",
          ...     skip_leading_rows=1,
          ... )
          >>> print(f"Cargadas {result['rows_loaded']} filas")
      """
      format_map = {
          "CSV": bigquery.SourceFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.SourceFormat.AVRO,
          "PARQUET": bigquery.SourceFormat.PARQUET,
          "ORC": bigquery.SourceFormat.ORC,
      }
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.LoadJobConfig(
          source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
          write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND),
          autodetect=autodetect,
          skip_leading_rows=skip_leading_rows,
      )

      table_ref = client._client.dataset(dataset_id).table(table_id)

      with open(file_path, "rb") as f:
          job = client._client.load_table_from_file(f, table_ref, job_config=job_config)

      result = job.result()

      return {
          "job_id": job.job_id,
          "rows_loaded": result.output_rows,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_export_to_gcs(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      destination_uri: str,
      destination_format: str = "CSV",
      compression: str = "NONE",
  ) -> dict:
      """Exporta una tabla BigQuery a Google Cloud Storage.

      Usa extract_table del SDK para crear un ExtractJob. Espera la finalizacion
      del job con job.result().

      Args:
          client: Cliente autenticado de BigQuery.
          dataset_id: ID del dataset de origen.
          table_id: ID de la tabla de origen.
          destination_uri: URI de destino en GCS. Para multiples archivos usar
                           wildcard: "gs://bucket/prefix_*.csv".
          destination_format: Formato de exportacion: "CSV", "NEWLINE_DELIMITED_JSON",
                              "AVRO", "PARQUET". Default: "CSV".
          compression: Compresion: "NONE", "GZIP", "SNAPPY", "DEFLATE". Default: "NONE".

      Returns:
          {"job_id": str, "destination_uri": str, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_export_to_gcs(
          ...     client, "my_dataset", "my_table",
          ...     "gs://my-bucket/export/data_*.csv",
          ...     compression="GZIP",
          ... )
          >>> print(f"Exportado a {result['destination_uri']}, job: {result['job_id']}")
      """
      format_map = {
          "CSV": bigquery.DestinationFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.DestinationFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.DestinationFormat.AVRO,
          "PARQUET": bigquery.DestinationFormat.PARQUET,
      }
      compression_map = {
          "NONE": bigquery.Compression.NONE,
          "GZIP": bigquery.Compression.GZIP,
          "SNAPPY": bigquery.Compression.SNAPPY,
          "DEFLATE": bigquery.Compression.DEFLATE,
      }

      job_config = bigquery.ExtractJobConfig(
          destination_format=format_map.get(destination_format, bigquery.DestinationFormat.CSV),
          compression=compression_map.get(compression, bigquery.Compression.NONE),
      )

      table_ref = client._client.dataset(dataset_id).table(table_id)
      job = client._client.extract_table(table_ref, destination_uri, job_config=job_config)
      job.result()

      return {
          "job_id": job.job_id,
          "destination_uri": destination_uri,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_copy_table(
      client: BQClient,
      source_dataset: str,
      source_table: str,
      dest_dataset: str,
      dest_table: str,
      write_disposition: str = "WRITE_EMPTY",
  ) -> dict:
      """Copia una tabla BigQuery a otro dataset o tabla dentro del mismo proyecto.

      Usa copy_table del SDK para crear un CopyJob. Espera la finalizacion
      del job con job.result(). No copia datos entre proyectos distintos.

      Args:
          client: Cliente autenticado de BigQuery.
          source_dataset: ID del dataset de origen.
          source_table: ID de la tabla de origen.
          dest_dataset: ID del dataset de destino.
          dest_table: ID de la tabla de destino.
          write_disposition: Comportamiento si la tabla destino existe: "WRITE_EMPTY"
                             (falla si existe), "WRITE_APPEND", "WRITE_TRUNCATE".
                             Default: "WRITE_EMPTY".

      Returns:
          {"job_id": str, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla de origen no existe.
          google.api_core.exceptions.Conflict: Si destino existe y write_disposition es WRITE_EMPTY.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_copy_table(
          ...     client,
          ...     "production", "users",
          ...     "staging", "users_backup",
          ...     write_disposition="WRITE_TRUNCATE",
          ... )
          >>> print(f"Copia completada: {result['status']}, job: {result['job_id']}")
      """
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.CopyJobConfig(
          write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_EMPTY),
      )

      source_ref = client._client.dataset(source_dataset).table(source_table)
      dest_ref = client._client.dataset(dest_dataset).table(dest_table)

      job = client._client.copy_table(source_ref, dest_ref, job_config=job_config)
      job.result()

      return {
          "job_id": job.job_id,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }

params_schema:
  {"params":[{"name":"client","desc":"cliente BQClient autenticado contra el proyecto GCP"},{"name":"source_dataset","desc":"ID del dataset de origen en BigQuery"},{"name":"source_table","desc":"ID de la tabla de origen en BigQuery"},{"name":"dest_dataset","desc":"ID del dataset de destino en BigQuery (puede ser el mismo que el origen)"},{"name":"dest_table","desc":"ID de la tabla de destino; si no existe, BigQuery la crea automaticamente"},{"name":"write_disposition","desc":"comportamiento si la tabla destino ya existe: WRITE_EMPTY falla, WRITE_APPEND agrega, WRITE_TRUNCATE sobreescribe"}],"output":"dict con {job_id: ID del CopyJob, status: DONE o FAILED}"}


id: bq_create_dataset_py_infra
name: bq_create_dataset
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","dataset","create","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/datasets.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 1164010f02ffc9624eb1ee057a44fed4cad8e678350f33382d9b3ddc7a8e784f
signature:
  def bq_create_dataset(client: BQClient, dataset_id: str, location: str = 'US', description: str = '', labels: dict[str, str] | None = None, default_table_expiration_ms: int = 0) -> dict

description:
  Crea un dataset en Google BigQuery con ubicacion, descripcion y labels. Usa client._client.create_dataset() del SDK oficial.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.datasets import bq_create_dataset

  client = bq_auth("my-project")
  ds = bq_create_dataset(
      client,
      "analytics",
      location="EU",
      description="Data warehouse principal",
      labels={"env": "prod", "team": "data"},
  )
  print(ds["full_id"], ds["location"])
  # my-project.analytics EU
  ```

notes:
  Lanza `google.api_core.exceptions.Conflict` (409) si el dataset ya existe.
  El `full_id` tiene formato `{project}.{dataset_id}` y puede usarse directamente como referencia en otras llamadas al SDK.
  `default_table_expiration_ms` aplica a todas las tablas nuevas del dataset; las tablas existentes no se ven afectadas.

code:
  """CRUD de datasets en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_dataset(
      client: BQClient,
      dataset_id: str,
      location: str = "US",
      description: str = "",
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int = 0,
  ) -> dict:
      """Crea un dataset en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset (solo el nombre, sin proyecto).
          location: Ubicacion geografica (US, EU, us-central1, etc.).
          description: Descripcion opcional.
          labels: Labels key-value opcionales.
          default_table_expiration_ms: Expiracion por defecto de tablas en ms. 0 = sin expiracion.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels, created, modified,
          default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.Conflict: Si el dataset ya existe (409).

      Example:
          >>> ds = bq_create_dataset(client, "analytics", location="EU", description="Data warehouse")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = bigquery.Dataset(ref)
      ds.location = location
      if description:
          ds.description = description
      if labels:
          ds.labels = labels
      if default_table_expiration_ms > 0:
          ds.default_table_expiration_ms = default_table_expiration_ms
      created = client._client.create_dataset(ds)
      return _dataset_to_dict(created)


  def bq_get_dataset(client: BQClient, dataset_id: str) -> dict:
      """Obtiene los detalles de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels,
          created, modified, default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe (404).

      Example:
          >>> ds = bq_get_dataset(client, "analytics")
          >>> print(ds["location"], ds["description"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      return _dataset_to_dict(ds)


  def bq_list_datasets(client: BQClient) -> list[dict]:
      """Lista todos los datasets del proyecto.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: dataset_id, project, full_id.

      Example:
          >>> datasets = bq_list_datasets(client)
          >>> for ds in datasets:
          ...     print(ds["dataset_id"], ds["full_id"])
      """
      return [
          {
              "dataset_id": ds.dataset_id,
              "project": ds.project,
              "full_id": f"{ds.project}.{ds.dataset_id}",
          }
          for ds in client._client.list_datasets()
      ]


  def bq_update_dataset(
      client: BQClient,
      dataset_id: str,
      description: str | None = None,
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int | None = None,
  ) -> dict:
      """Actualiza campos de un dataset.

      Solo se modifican los campos pasados (no-None).

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          description: Nueva descripcion (None = no cambiar).
          labels: Nuevos labels (None = no cambiar).
          default_table_expiration_ms: Nueva expiracion de tablas en ms (None = no cambiar).

      Returns:
          Dict con el dataset actualizado.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> bq_update_dataset(client, "analytics", description="Updated description")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      fields = []
      if description is not None:
          ds.description = description
          fields.append("description")
      if labels is not None:
          ds.labels = labels
          fields.append("labels")
      if default_table_expiration_ms is not None:
          ds.default_table_expiration_ms = default_table_expiration_ms
          fields.append("default_table_expiration_ms")
      if not fields:
          return _dataset_to_dict(ds)
      updated = client._client.update_dataset(ds, fields)
      return _dataset_to_dict(updated)


  def bq_delete_dataset(
      client: BQClient,
      dataset_id: str,
      delete_contents: bool = False,
  ) -> None:
      """Elimina un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          delete_contents: Si True, elimina todas las tablas del dataset.
              Si False y el dataset tiene tablas, falla.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si tiene tablas y delete_contents=False.

      Example:
          >>> bq_delete_dataset(client, "temp_analytics", delete_contents=True)
      """
      ref = f"{client.project_id}.{dataset_id}"
      client._client.delete_dataset(ref, delete_contents=delete_contents)


  def _dataset_to_dict(ds) -> dict:
      """Convierte un objeto Dataset del SDK a dict plano."""
      return {
          "dataset_id": ds.dataset_id,
          "project": ds.project,
          "full_id": f"{ds.project}.{ds.dataset_id}",
          "location": ds.location,
          "description": ds.description or "",
          "labels": dict(ds.labels) if ds.labels else {},
          "created": ds.created.isoformat() if ds.created else None,
          "modified": ds.modified.isoformat() if ds.modified else None,
          "default_table_expiration_ms": ds.default_table_expiration_ms,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset dentro del proyecto (sin prefijo de proyecto)"},{"name":"location","desc":"ubicacion geografica del dataset: US, EU, us-central1, europe-west1, etc."},{"name":"description","desc":"descripcion opcional del dataset"},{"name":"labels","desc":"dict de labels key-value para categorizar el dataset"},{"name":"default_table_expiration_ms","desc":"tiempo de expiracion por defecto para tablas en milisegundos; 0 = sin expiracion"}],"output":"dict con dataset_id, project, full_id, location, description, labels, created, modified, default_table_expiration_ms"}


id: bq_create_routine_py_infra
name: bq_create_routine
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","routine","udf","create","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/routines.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 9104fd1de945b4777fa0a26ec2fa8cf0775310b6df602c695e7a52fbaa121a4e
signature:
  def bq_create_routine(client: BQClient, dataset_id: str, routine_id: str, body: str, routine_type: str = 'SCALAR_FUNCTION', language: str = 'SQL', arguments: list[dict] | None = None, return_type: str = '', description: str = '') -> dict

description:
  Crea una routine (UDF scalar, tabla o stored procedure) en BigQuery. Soporta SQL, JavaScript y Python.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.routines import bq_create_routine

  client = bq_auth("my-project")

  # UDF escalar SQL
  fn = bq_create_routine(
      client,
      dataset_id="analytics",
      routine_id="double_value",
      body="x * 2",
      arguments=[{"name": "x", "data_type": "INT64"}],
      return_type="INT64",
      description="Duplica un entero",
  )
  print(fn["routine_id"], fn["routine_type"], fn["language"])
  # double_value SCALAR_FUNCTION SQL

  # Stored procedure SQL
  bq_create_routine(
      client,
      dataset_id="analytics",
      routine_id="refresh_summary",
      body="INSERT INTO summary SELECT * FROM raw WHERE date = CURRENT_DATE();",
      routine_type="PROCEDURE",
  )
  ```

notes:
  Lanza `google.api_core.exceptions.Conflict` (409) si la routine ya existe. Para actualizar una routine existente, eliminarla primero con `bq_delete_routine` y recrearla, o usar `client._client.update_routine()` directamente.

  Los `data_type` de los argumentos deben ser constantes de `bigquery.StandardSqlTypeNames`: `INT64`, `FLOAT64`, `STRING`, `BOOL`, `BYTES`, `DATE`, `DATETIME`, `TIMESTAMP`, `TIME`, `NUMERIC`, `BIGNUMERIC`, `JSON`, `ARRAY`, `STRUCT`.

  Las routines JavaScript permiten librerias externas via `imported_libraries` (no expuesto en este wrapper).

code:
  """Gestion de routines (UDFs y stored procedures) en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
      body: str,
      routine_type: str = "SCALAR_FUNCTION",
      language: str = "SQL",
      arguments: list[dict] | None = None,
      return_type: str = "",
      description: str = "",
  ) -> dict:
      """Crea una routine (UDF o stored procedure) en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset donde crear la routine.
          routine_id: ID/nombre de la routine.
          body: Cuerpo de la routine (SQL, JavaScript o Python).
          routine_type: Tipo: "SCALAR_FUNCTION", "TABLE_VALUED_FUNCTION", "PROCEDURE".
          language: Lenguaje: "SQL", "JAVASCRIPT", "PYTHON".
          arguments: Lista de argumentos, cada uno: {"name": "x", "data_type": "STRING"}.
          return_type: Tipo de retorno para funciones (ej: "STRING", "INT64"). No aplica a procedures.
          description: Descripcion opcional.

      Returns:
          Dict con: routine_id, dataset_id, project, routine_type, language, body, created, modified.

      Raises:
          google.api_core.exceptions.Conflict: Si la routine ya existe.

      Example:
          >>> bq_create_routine(client, "analytics", "double_value",
          ...     body="x * 2",
          ...     arguments=[{"name": "x", "data_type": "INT64"}],
          ...     return_type="INT64")
      """
      ref = bigquery.RoutineReference.from_string(
          f"{client.project_id}.{dataset_id}.{routine_id}"
      )
      routine = bigquery.Routine(ref)
      routine.type_ = routine_type
      routine.language = language
      routine.body = body
      if description:
          routine.description = description
      if arguments:
          routine.arguments = [
              bigquery.RoutineArgument(
                  name=arg["name"],
                  data_type=bigquery.StandardSqlDataType(
                      type_kind=getattr(
                          bigquery.StandardSqlTypeNames, arg["data_type"]
                      )
                  ),
              )
              for arg in arguments
          ]
      if return_type:
          routine.return_type = bigquery.StandardSqlDataType(
              type_kind=getattr(bigquery.StandardSqlTypeNames, return_type)
          )
      created = client._client.create_routine(routine)
      return _routine_to_dict(created)


  def bq_list_routines(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista routines de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Lista de dicts con: routine_id, dataset_id, project, routine_type, language.

      Example:
          >>> routines = bq_list_routines(client, "analytics")
          >>> for r in routines:
          ...     print(r["routine_id"], r["routine_type"], r["language"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      return [
          {
              "routine_id": r.routine_id,
              "dataset_id": dataset_id,
              "project": client.project_id,
              "routine_type": r.type_,
              "language": r.language,
          }
          for r in client._client.list_routines(ref)
      ]


  def bq_delete_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
  ) -> None:
      """Elimina una routine.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          routine_id: ID de la routine a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la routine no existe.

      Example:
          >>> bq_delete_routine(client, "analytics", "double_value")
      """
      ref = f"{client.project_id}.{dataset_id}.{routine_id}"
      client._client.delete_routine(ref)


  def _routine_to_dict(routine) -> dict:
      """Convierte un objeto Routine del SDK a dict plano."""
      return {
          "routine_id": routine.routine_id,
          "dataset_id": routine.dataset_id,
          "project": routine.project,
          "routine_type": routine.type_,
          "language": routine.language,
          "body": routine.body,
          "description": routine.description or "",
          "created": routine.created.isoformat() if routine.created else None,
          "modified": routine.modified.isoformat() if routine.modified else None,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset donde se crea la routine"},{"name":"routine_id","desc":"nombre/identificador de la routine dentro del dataset"},{"name":"body","desc":"cuerpo de la routine: expresion SQL, bloque JavaScript o codigo Python segun el lenguaje"},{"name":"routine_type","desc":"tipo de routine: SCALAR_FUNCTION para UDFs escalares, TABLE_VALUED_FUNCTION para UDFs de tabla, PROCEDURE para stored procedures"},{"name":"language","desc":"lenguaje de implementacion: SQL, JAVASCRIPT o PYTHON"},{"name":"arguments","desc":"lista de argumentos, cada uno como dict con claves 'name' y 'data_type' (ej: INT64, STRING, FLOAT64)"},{"name":"return_type","desc":"tipo de dato que retorna la funcion (ej: INT64, STRING); ignorado para PROCEDURE"},{"name":"description","desc":"descripcion opcional de la routine"}],"output":"dict con routine_id, dataset_id, project, routine_type, language, body, description, created y modified (ISO 8601)"}


id: bq_create_table_py_infra
name: bq_create_table
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","table","create","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/tables.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 292548adc00e0837a09fded0b2c83a355600b24b53e560696930f2ddb9476d8b
signature:
  def bq_create_table(client: BQClient, dataset_id: str, table_id: str, schema: list[dict], partitioning: dict | None = None, clustering: list[str] | None = None, description: str = '', labels: dict | None = None) -> dict

description:
  Crea una tabla en BigQuery con schema, particionamiento opcional y clustering. Usa client._client.create_table() del SDK oficial.

example:
  ```python
  from bigquery import bq_auth, bq_create_table

  client = bq_auth("mi-proyecto")

  tabla = bq_create_table(
      client,
      dataset_id="ventas_ds",
      table_id="transacciones",
      schema=[
          {"name": "id", "type": "INTEGER", "mode": "REQUIRED", "description": "ID unico"},
          {"name": "fecha", "type": "DATE", "mode": "NULLABLE", "description": "Fecha de la transaccion"},
          {"name": "monto", "type": "FLOAT", "mode": "NULLABLE", "description": "Monto en USD"},
          {"name": "pais", "type": "STRING", "mode": "NULLABLE", "description": "Codigo de pais"},
      ],
      partitioning={"type": "MONTH", "field": "fecha"},
      clustering=["pais"],
      description="Transacciones de ventas por mes",
      labels={"env": "prod", "team": "data"},
  )
  print(tabla["full_id"])
  ```

notes:
  El schema se convierte internamente de dicts a objetos `bigquery.SchemaField`. El particionamiento `TIME` soporta DAY, MONTH, YEAR y HOUR sobre columnas DATE/DATETIME/TIMESTAMP. Si `field` se omite en `partitioning`, BigQuery usa la pseudo-columna `_PARTITIONTIME`. El clustering requiere que las columnas existan en el schema y mejora rendimiento en filtros frecuentes.

code:
  """CRUD de tablas en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  # ---------------------------------------------------------------------------
  # Helpers de serializacion
  # ---------------------------------------------------------------------------

  def _schema_field_to_dict(field: bigquery.SchemaField) -> dict:
      """Convierte un SchemaField a dict serializable."""
      result = {
          "name": field.name,
          "type": field.field_type,
          "mode": field.mode,
          "description": field.description or "",
      }
      if field.fields:
          result["fields"] = [_schema_field_to_dict(f) for f in field.fields]
      return result


  def _schema_to_dicts(schema: list) -> list[dict]:
      """Convierte una lista de SchemaField a lista de dicts."""
      return [_schema_field_to_dict(f) for f in schema]


  def _dict_to_schema_field(d: dict) -> bigquery.SchemaField:
      """Convierte un dict a SchemaField."""
      nested = [_dict_to_schema_field(f) for f in d.get("fields", [])]
      return bigquery.SchemaField(
          name=d["name"],
          field_type=d.get("type", "STRING"),
          mode=d.get("mode", "NULLABLE"),
          description=d.get("description", ""),
          fields=nested,
      )


  def _table_to_dict(table: bigquery.Table) -> dict:
      """Convierte un objeto Table del SDK a dict plano serializable."""
      partitioning = None
      if table.time_partitioning:
          partitioning = {
              "type": table.time_partitioning.type_,
              "field": table.time_partitioning.field or "",
          }
      elif table.range_partitioning:
          partitioning = {
              "type": "RANGE",
              "field": table.range_partitioning.field,
          }

      clustering = None
      if table.clustering_fields:
          clustering = list(table.clustering_fields)

      return {
          "table_id": table.table_id,
          "dataset_id": table.dataset_id,
          "project": table.project,
          "full_id": table.full_table_id or f"{table.project}.{table.dataset_id}.{table.table_id}",
          "schema": _schema_to_dicts(table.schema or []),
          "num_rows": table.num_rows,
          "num_bytes": table.num_bytes,
          "created": table.created.isoformat() if table.created else None,
          "modified": table.modified.isoformat() if table.modified else None,
          "type": table.table_type or "TABLE",
          "partitioning": partitioning,
          "clustering": clustering,
          "description": table.description or "",
          "labels": dict(table.labels or {}),
      }


  # ---------------------------------------------------------------------------
  # Funciones CRUD
  # ---------------------------------------------------------------------------

  def bq_create_table(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      schema: list[dict],
      partitioning: dict | None = None,
      clustering: list[str] | None = None,
      description: str = "",
      labels: dict | None = None,
  ) -> dict:
      """Crea una tabla en BigQuery con schema, particionamiento y clustering opcionales.

      Usa `client._client.create_table()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset donde crear la tabla.
          table_id: ID (nombre) de la tabla a crear.
          schema: Lista de dicts con definicion de columnas. Cada dict:
              {"name": "col", "type": "STRING", "mode": "NULLABLE", "description": "..."}
              Tipos validos: STRING, INTEGER, FLOAT, BOOLEAN, BYTES, DATE, DATETIME,
              TIME, TIMESTAMP, RECORD, NUMERIC, BIGNUMERIC, JSON, GEOGRAPHY.
              Modos: NULLABLE, REQUIRED, REPEATED.
          partitioning: Dict de configuracion de particion o None. Ejemplo:
              {"type": "DAY", "field": "created_at"}
              Tipos: DAY, MONTH, YEAR, HOUR. Field vacio usa pseudo-columna _PARTITIONTIME.
          clustering: Lista de columnas para clustering (max 4) o None.
              Ejemplo: ["country", "status"]
          description: Descripcion de la tabla.
          labels: Etiquetas clave-valor para la tabla. Ejemplo: {"env": "prod"}.

      Returns:
          Dict con metadata de la tabla creada: table_id, dataset_id, project,
          full_id, schema, num_rows, num_bytes, created, modified, type,
          partitioning, clustering, description, labels.

      Raises:
          google.api_core.exceptions.Conflict: Si la tabla ya existe.
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si el schema es invalido.

      Example:
          >>> table = bq_create_table(client, "mi_dataset", "ventas", [
          ...     {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          ...     {"name": "fecha", "type": "DATE", "mode": "NULLABLE"},
          ...     {"name": "monto", "type": "FLOAT", "mode": "NULLABLE"},
          ... ], partitioning={"type": "MONTH", "field": "fecha"},
          ...    clustering=["id"])
          >>> print(table["full_id"])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      bq_schema = [_dict_to_schema_field(f) for f in schema]

      table = bigquery.Table(table_ref, schema=bq_schema)

      if partitioning:
          table.time_partitioning = bigquery.TimePartitioning(
              type_=partitioning.get("type", "DAY"),
              field=partitioning.get("field") or None,
          )

      if clustering:
          table.clustering_fields = clustering

      if description:
          table.description = description

      if labels:
          table.labels = labels

      created = client._client.create_table(table)
      return _table_to_dict(created)


  def bq_get_table(client: BQClient, dataset_id: str, table_id: str) -> dict:
      """Obtiene los metadatos completos de una tabla BigQuery.

      Usa `client._client.get_table()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla.

      Returns:
          Dict con: table_id, dataset_id, project, full_id, schema (lista de dicts),
          num_rows, num_bytes, created (ISO 8601), modified (ISO 8601), type
          (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL), partitioning (dict o None),
          clustering (lista de strings o None), description, labels.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> tabla = bq_get_table(client, "mi_dataset", "ventas")
          >>> print(tabla["num_rows"], tabla["schema"])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)
      return _table_to_dict(table)


  def bq_list_tables(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista todas las tablas de un dataset BigQuery.

      Usa `client._client.list_tables()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset a listar.

      Returns:
          Lista de dicts resumidos, uno por tabla. Cada dict contiene:
          table_id, full_id, type (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL).

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> tablas = bq_list_tables(client, "mi_dataset")
          >>> for t in tablas:
          ...     print(t["table_id"], t["type"])
      """
      dataset_ref = f"{client.project_id}.{dataset_id}"
      tables = client._client.list_tables(dataset_ref)
      return [
          {
              "table_id": t.table_id,
              "full_id": f"{t.project}.{t.dataset_id}.{t.table_id}",
              "type": t.table_type or "TABLE",
          }
          for t in tables
      ]


  def bq_update_table(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      schema: list[dict] | None = None,
      description: str | None = None,
      labels: dict | None = None,
  ) -> dict:
      """Actualiza metadatos de una tabla BigQuery.

      Usa `client._client.update_table()` del SDK oficial. Solo modifica los
      campos no-None. Para schema, BigQuery SOLO permite agregar columnas nuevas
      al final — no se pueden eliminar ni modificar columnas existentes.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a actualizar.
          schema: Lista de dicts con el schema completo nuevo (incluye columnas
              existentes + nuevas). Solo se permiten adiciones. None = sin cambios.
          description: Nueva descripcion de la tabla. None = sin cambios.
          labels: Nuevas etiquetas clave-valor. None = sin cambios.

      Returns:
          Dict con la metadata actualizada de la tabla (misma estructura que bq_get_table).

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.BadRequest: Si se intenta eliminar columnas.

      Example:
          >>> tabla = bq_update_table(client, "mi_dataset", "ventas",
          ...     description="Tabla de ventas actualizada",
          ...     labels={"env": "prod", "team": "data"})
          >>> tabla = bq_update_table(client, "mi_dataset", "ventas",
          ...     schema=[
          ...         {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          ...         {"name": "nueva_col", "type": "STRING", "mode": "NULLABLE"},
          ...     ])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)

      fields_to_update = []

      if schema is not None:
          table.schema = [_dict_to_schema_field(f) for f in schema]
          fields_to_update.append("schema")

      if description is not None:
          table.description = description
          fields_to_update.append("description")

      if labels is not None:
          table.labels = labels
          fields_to_update.append("labels")

      if not fields_to_update:
          return _table_to_dict(table)

      updated = client._client.update_table(table, fields_to_update)
      return _table_to_dict(updated)


  def bq_delete_table(client: BQClient, dataset_id: str, table_id: str) -> None:
      """Elimina permanentemente una tabla de BigQuery.

      Usa `client._client.delete_table()` del SDK oficial. IRREVERSIBLE — no hay
      papelera de reciclaje. Considerar exportar datos antes de eliminar.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> bq_delete_table(client, "mi_dataset", "tabla_temporal")
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      client._client.delete_table(table_ref)


  def bq_preview_rows(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      max_results: int = 10,
  ) -> dict:
      """Obtiene una muestra de filas de una tabla BigQuery sin ejecutar query.

      Usa `client._client.list_rows()` del SDK oficial — no genera costes de
      procesamiento de bytes (no es una query SQL). Ideal para vista previa rapida.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a previsualizar.
          max_results: Numero maximo de filas a retornar. Default: 10.

      Returns:
          Dict con:
          - columns: lista de strings con nombres de columnas
          - rows: lista de listas con valores de cada fila
          - total_rows: numero total de filas en la tabla (no en el preview)

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> preview = bq_preview_rows(client, "mi_dataset", "ventas", max_results=5)
          >>> print(preview["columns"])
          >>> for row in preview["rows"]:
          ...     print(row)
          >>> print(f"Total en tabla: {preview['total_rows']}")
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)
      rows_iter = client._client.list_rows(table, max_results=max_results)

      columns = [f.name for f in rows_iter.schema]
      rows = [list(row.values()) for row in rows_iter]

      return {
          "columns": columns,
          "rows": rows,
          "total_rows": table.num_rows,
      }

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado BQClient obtenido con bq_auth"},{"name":"dataset_id","desc":"ID del dataset de BigQuery donde crear la tabla"},{"name":"table_id","desc":"nombre (ID) de la tabla a crear"},{"name":"schema","desc":"lista de dicts con definicion de columnas: [{name, type, mode, description}]. Tipos: STRING, INTEGER, FLOAT, BOOLEAN, DATE, TIMESTAMP, RECORD, etc."},{"name":"partitioning","desc":"dict opcional con tipo y campo de particion: {type: DAY|MONTH|YEAR|HOUR, field: nombre_col}. None = sin particion"},{"name":"clustering","desc":"lista de hasta 4 columnas para clustering (ordenacion fisica). None = sin clustering"},{"name":"description","desc":"descripcion legible de la tabla (vacio = sin descripcion)"},{"name":"labels","desc":"etiquetas clave-valor para la tabla, ej: {env: prod, team: data}"}],"output":"dict con metadata de la tabla creada: table_id, dataset_id, project, full_id, schema, num_rows, num_bytes, created, modified, type, partitioning, clustering, description, labels"}


id: bq_delete_dataset_py_infra
name: bq_delete_dataset
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_delete_dataset(client: BQClient, dataset_id: str, delete_contents: bool = False) -> None
tags: ["bigquery","gcp","dataset","delete","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/datasets.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 5b136a42c62916e7b69261eb15bde708f91a7f827b8e29d8fd03120840c21cd6
description:
  Elimina un dataset de Google BigQuery. IRREVERSIBLE. Usa client._client.delete_dataset() del SDK oficial.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.datasets import bq_delete_dataset

  client = bq_auth("my-project")

  # Eliminar dataset vacio
  bq_delete_dataset(client, "temp_dataset")

  # Eliminar dataset con todas sus tablas
  bq_delete_dataset(client, "temp_analytics", delete_contents=True)
  ```

notes:
  IRREVERSIBLE. Todos los datos del dataset se pierden permanentemente.
  Lanza `google.api_core.exceptions.NotFound` (404) si el dataset no existe.
  Lanza `google.api_core.exceptions.BadRequest` (400) si el dataset contiene tablas y `delete_contents=False`.
  Por seguridad el valor por defecto de `delete_contents` es `False` — requiere confirmacion explicita para borrar contenido.

code:
  """CRUD de datasets en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_dataset(
      client: BQClient,
      dataset_id: str,
      location: str = "US",
      description: str = "",
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int = 0,
  ) -> dict:
      """Crea un dataset en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset (solo el nombre, sin proyecto).
          location: Ubicacion geografica (US, EU, us-central1, etc.).
          description: Descripcion opcional.
          labels: Labels key-value opcionales.
          default_table_expiration_ms: Expiracion por defecto de tablas en ms. 0 = sin expiracion.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels, created, modified,
          default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.Conflict: Si el dataset ya existe (409).

      Example:
          >>> ds = bq_create_dataset(client, "analytics", location="EU", description="Data warehouse")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = bigquery.Dataset(ref)
      ds.location = location
      if description:
          ds.description = description
      if labels:
          ds.labels = labels
      if default_table_expiration_ms > 0:
          ds.default_table_expiration_ms = default_table_expiration_ms
      created = client._client.create_dataset(ds)
      return _dataset_to_dict(created)


  def bq_get_dataset(client: BQClient, dataset_id: str) -> dict:
      """Obtiene los detalles de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels,
          created, modified, default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe (404).

      Example:
          >>> ds = bq_get_dataset(client, "analytics")
          >>> print(ds["location"], ds["description"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      return _dataset_to_dict(ds)


  def bq_list_datasets(client: BQClient) -> list[dict]:
      """Lista todos los datasets del proyecto.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: dataset_id, project, full_id.

      Example:
          >>> datasets = bq_list_datasets(client)
          >>> for ds in datasets:
          ...     print(ds["dataset_id"], ds["full_id"])
      """
      return [
          {
              "dataset_id": ds.dataset_id,
              "project": ds.project,
              "full_id": f"{ds.project}.{ds.dataset_id}",
          }
          for ds in client._client.list_datasets()
      ]


  def bq_update_dataset(
      client: BQClient,
      dataset_id: str,
      description: str | None = None,
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int | None = None,
  ) -> dict:
      """Actualiza campos de un dataset.

      Solo se modifican los campos pasados (no-None).

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          description: Nueva descripcion (None = no cambiar).
          labels: Nuevos labels (None = no cambiar).
          default_table_expiration_ms: Nueva expiracion de tablas en ms (None = no cambiar).

      Returns:
          Dict con el dataset actualizado.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> bq_update_dataset(client, "analytics", description="Updated description")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      fields = []
      if description is not None:
          ds.description = description
          fields.append("description")
      if labels is not None:
          ds.labels = labels
          fields.append("labels")
      if default_table_expiration_ms is not None:
          ds.default_table_expiration_ms = default_table_expiration_ms
          fields.append("default_table_expiration_ms")
      if not fields:
          return _dataset_to_dict(ds)
      updated = client._client.update_dataset(ds, fields)
      return _dataset_to_dict(updated)


  def bq_delete_dataset(
      client: BQClient,
      dataset_id: str,
      delete_contents: bool = False,
  ) -> None:
      """Elimina un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          delete_contents: Si True, elimina todas las tablas del dataset.
              Si False y el dataset tiene tablas, falla.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si tiene tablas y delete_contents=False.

      Example:
          >>> bq_delete_dataset(client, "temp_analytics", delete_contents=True)
      """
      ref = f"{client.project_id}.{dataset_id}"
      client._client.delete_dataset(ref, delete_contents=delete_contents)


  def _dataset_to_dict(ds) -> dict:
      """Convierte un objeto Dataset del SDK a dict plano."""
      return {
          "dataset_id": ds.dataset_id,
          "project": ds.project,
          "full_id": f"{ds.project}.{ds.dataset_id}",
          "location": ds.location,
          "description": ds.description or "",
          "labels": dict(ds.labels) if ds.labels else {},
          "created": ds.created.isoformat() if ds.created else None,
          "modified": ds.modified.isoformat() if ds.modified else None,
          "default_table_expiration_ms": ds.default_table_expiration_ms,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset a eliminar (sin prefijo de proyecto)"},{"name":"delete_contents","desc":"si True elimina todas las tablas y vistas del dataset antes de borrarlo; si False falla si el dataset contiene objetos"}],"output":"None"}


id: bq_delete_routine_py_infra
name: bq_delete_routine
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_delete_routine(client: BQClient, dataset_id: str, routine_id: str) -> None
description: Elimina una routine de un dataset.
tags: ["bigquery","gcp","routine","udf","delete","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/routines.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 792f3cb4a47b1f74b78bfcbe3f21b5d4aa6968366b7ad50008e3939eebc78ab1
example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.routines import bq_delete_routine

  client = bq_auth("my-project")
  bq_delete_routine(client, "analytics", "double_value")
  # La routine queda eliminada permanentemente
  ```

notes:
  Lanza `google.api_core.exceptions.NotFound` (404) si la routine no existe. La operacion es permanente e irreversible.

  Para eliminar y recrear una routine actualizada, combinar con `bq_create_routine`:

  ```python
  bq_delete_routine(client, "analytics", "double_value")
  bq_create_routine(client, "analytics", "double_value", body="x * 3", ...)
  ```

code:
  """Gestion de routines (UDFs y stored procedures) en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
      body: str,
      routine_type: str = "SCALAR_FUNCTION",
      language: str = "SQL",
      arguments: list[dict] | None = None,
      return_type: str = "",
      description: str = "",
  ) -> dict:
      """Crea una routine (UDF o stored procedure) en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset donde crear la routine.
          routine_id: ID/nombre de la routine.
          body: Cuerpo de la routine (SQL, JavaScript o Python).
          routine_type: Tipo: "SCALAR_FUNCTION", "TABLE_VALUED_FUNCTION", "PROCEDURE".
          language: Lenguaje: "SQL", "JAVASCRIPT", "PYTHON".
          arguments: Lista de argumentos, cada uno: {"name": "x", "data_type": "STRING"}.
          return_type: Tipo de retorno para funciones (ej: "STRING", "INT64"). No aplica a procedures.
          description: Descripcion opcional.

      Returns:
          Dict con: routine_id, dataset_id, project, routine_type, language, body, created, modified.

      Raises:
          google.api_core.exceptions.Conflict: Si la routine ya existe.

      Example:
          >>> bq_create_routine(client, "analytics", "double_value",
          ...     body="x * 2",
          ...     arguments=[{"name": "x", "data_type": "INT64"}],
          ...     return_type="INT64")
      """
      ref = bigquery.RoutineReference.from_string(
          f"{client.project_id}.{dataset_id}.{routine_id}"
      )
      routine = bigquery.Routine(ref)
      routine.type_ = routine_type
      routine.language = language
      routine.body = body
      if description:
          routine.description = description
      if arguments:
          routine.arguments = [
              bigquery.RoutineArgument(
                  name=arg["name"],
                  data_type=bigquery.StandardSqlDataType(
                      type_kind=getattr(
                          bigquery.StandardSqlTypeNames, arg["data_type"]
                      )
                  ),
              )
              for arg in arguments
          ]
      if return_type:
          routine.return_type = bigquery.StandardSqlDataType(
              type_kind=getattr(bigquery.StandardSqlTypeNames, return_type)
          )
      created = client._client.create_routine(routine)
      return _routine_to_dict(created)


  def bq_list_routines(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista routines de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Lista de dicts con: routine_id, dataset_id, project, routine_type, language.

      Example:
          >>> routines = bq_list_routines(client, "analytics")
          >>> for r in routines:
          ...     print(r["routine_id"], r["routine_type"], r["language"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      return [
          {
              "routine_id": r.routine_id,
              "dataset_id": dataset_id,
              "project": client.project_id,
              "routine_type": r.type_,
              "language": r.language,
          }
          for r in client._client.list_routines(ref)
      ]


  def bq_delete_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
  ) -> None:
      """Elimina una routine.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          routine_id: ID de la routine a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la routine no existe.

      Example:
          >>> bq_delete_routine(client, "analytics", "double_value")
      """
      ref = f"{client.project_id}.{dataset_id}.{routine_id}"
      client._client.delete_routine(ref)


  def _routine_to_dict(routine) -> dict:
      """Convierte un objeto Routine del SDK a dict plano."""
      return {
          "routine_id": routine.routine_id,
          "dataset_id": routine.dataset_id,
          "project": routine.project,
          "routine_type": routine.type_,
          "language": routine.language,
          "body": routine.body,
          "description": routine.description or "",
          "created": routine.created.isoformat() if routine.created else None,
          "modified": routine.modified.isoformat() if routine.modified else None,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset que contiene la routine"},{"name":"routine_id","desc":"nombre/identificador de la routine a eliminar"}],"output":"None; lanza NotFound si la routine no existe"}


