diff --git a/frontend/functions/ui/alert.md b/frontend/functions/ui/alert.md index 22917d57..dd06ac28 100644 --- a/frontend/functions/ui/alert.md +++ b/frontend/functions/ui/alert.md @@ -3,10 +3,10 @@ name: alert kind: component lang: ts domain: ui -version: "1.0.0" +version: "1.1.0" purity: impure -signature: "Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element" -description: "Alerta accesible con variantes default y destructive. Mantine Alert con slots para título, descripción y acción." +signature: "Alert(props: { variant?: 'default' | 'destructive' | 'success' | 'warning' | 'info' }): JSX.Element" +description: "Alerta accesible con 5 variantes semánticas (default, destructive, success, warning, info). Mantine Alert con slots para título, descripción y acción." tags: [alert, feedback, component, ui, notification, mantine] uses_functions: [] uses_types: [] @@ -21,13 +21,13 @@ test_file_path: "" file_path: "frontend/functions/ui/alert.tsx" props: - name: variant - type: "'default' | 'destructive'" + type: "'default' | 'destructive' | 'success' | 'warning' | 'info'" required: false - description: "Variante visual" + description: "Variante visual semántica." emits: [] has_state: false framework: react -variant: [default, destructive] +variant: [default, destructive, success, warning, info] source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" source_license: "MIT" source_file: "frontend/src/components/ui/alert.tsx" diff --git a/frontend/functions/ui/alert.tsx b/frontend/functions/ui/alert.tsx index da6ae025..cf18d70a 100644 --- a/frontend/functions/ui/alert.tsx +++ b/frontend/functions/ui/alert.tsx @@ -1,11 +1,14 @@ import * as React from 'react' import { Alert as MantineAlert, Box, Text } from '@mantine/core' -type AlertVariant = 'default' | 'destructive' +type AlertVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info' const variantColorMap: Record = { default: undefined, destructive: 'red', + success: 'green', + warning: 'yellow', + info: 'blue', } function Alert({ diff --git a/frontend/functions/ui/data_table.md b/frontend/functions/ui/data_table.md index c10e82f2..1b312cc4 100644 --- a/frontend/functions/ui/data_table.md +++ b/frontend/functions/ui/data_table.md @@ -3,10 +3,10 @@ name: data_table kind: component lang: ts domain: ui -version: "1.0.0" +version: "1.1.0" purity: impure signature: "DataTable(props: DataTableProps): JSX.Element" -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." +description: "Tabla de datos con sticky header, overflow scroll, heatmap por columna, formato condicional (number/datetime/currency), hover rows y densidad configurable (compact/cozy/roomy). Auto-detecta columnas desde la primera fila si no se proveen." tags: [table, data, heatmap, dashboard, component, ui, format, visualization] uses_functions: [] uses_types: [] @@ -44,6 +44,10 @@ props: type: "Error | null" required: false description: "Error a mostrar si la carga falló." + - name: density + type: "'compact' | 'cozy' | 'roomy'" + required: false + description: "Padding vertical y horizontal de filas. compact=4/xs, cozy=6/sm (default), roomy=10/md." emits: [] has_state: false framework: react diff --git a/frontend/functions/ui/data_table.tsx b/frontend/functions/ui/data_table.tsx index 385708ea..3a5d5dd1 100644 --- a/frontend/functions/ui/data_table.tsx +++ b/frontend/functions/ui/data_table.tsx @@ -10,6 +10,8 @@ interface ColumnDef { align?: 'left' | 'right' | 'center' } +type DataTableDensity = 'compact' | 'cozy' | 'roomy' + interface DataTableProps { data: Record[] columns?: ColumnDef[] @@ -18,6 +20,14 @@ interface DataTableProps { maxHeight?: number | string loading?: boolean error?: Error | null + /** Row padding preset. compact=4/8, cozy=6/12 (default), roomy=10/16. */ + density?: DataTableDensity +} + +const DENSITY_MAP: Record = { + compact: { py: 4, px: 'xs' }, + cozy: { py: 6, px: 'sm' }, + roomy: { py: 10, px: 'md' }, } function formatCell(value: unknown, format?: string): string { @@ -52,7 +62,9 @@ function DataTableComponent({ maxHeight = 500, loading = false, error = null, + density = 'cozy', }: DataTableProps) { + const pad = DENSITY_MAP[density] // Auto-detect columns from first row if not provided const effectiveColumns: ColumnDef[] = (columns && columns.length > 0) ? columns @@ -113,8 +125,8 @@ function DataTableComponent({ fw={500} c="dimmed" tt="uppercase" - py={6} - px="sm" + py={pad.py} + px={pad.px} > {col.label} @@ -131,8 +143,8 @@ function DataTableComponent({ key={col.key} style={{ textAlign: align, fontFamily: 'var(--mantine-font-family-monospace)', ...heatmapStyle(col.key, row[col.key]) }} fz="xs" - py={6} - px="sm" + py={pad.py} + px={pad.px} > {formatCell(row[col.key], col.format)} @@ -152,4 +164,4 @@ function DataTableComponent({ } export const DataTable = DataTableComponent -export type { DataTableProps, ColumnDef } +export type { DataTableProps, ColumnDef, DataTableDensity } diff --git a/frontend/functions/ui/funnel_chart.md b/frontend/functions/ui/funnel_chart.md new file mode 100644 index 00000000..c6540123 --- /dev/null +++ b/frontend/functions/ui/funnel_chart.md @@ -0,0 +1,101 @@ +--- +name: funnel_chart +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "FunnelChart(props: FunnelChartProps): JSX.Element" +description: "Visualiza un funnel de conversión con barras degradadas, valores formateados y tasa de conversión entre etapas como Badge semántico. Genérico — acepta cualquier array {stage,value}." +tags: [funnel, conversion, dashboard, component, ui, chart, acquisition, analytics] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["@mantine/core", react] +output: "Componente FunnelChart que renderiza un funnel vertical de conversión con porcentajes entre etapas." +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/funnel_chart.tsx" +props: + - name: data + type: "FunnelStage[]" + required: true + description: "Etapas del funnel. Cada etapa con { stage: string, value: number }." + - name: valueFormatter + type: "(value: number) => string" + required: false + description: "Formateador del valor absoluto. Default: compact (K/M/B)." + - name: showConversion + type: "boolean" + required: false + description: "Mostrar tasa de conversión entre etapas como Badge. Default true." + - name: barHeight + type: "number" + required: false + description: "Alto de cada barra en px. Default 28." + - name: goodThreshold + type: "number" + required: false + description: "Tasa de conversión (%) por encima de la cual el Badge es 'success'. Default 30." + - name: warnThreshold + type: "number" + required: false + description: "Tasa de conversión (%) por encima de la cual el Badge es 'info' (por debajo → 'warning'). Default 5." + - name: barLabel + type: "(stage: FunnelStage, pctOfMax: number) => string" + required: false + description: "Texto opcional dentro de cada barra." + - name: barColor + type: "string" + required: false + description: "Color o gradient CSS a aplicar a las barras. Default: gradient indigo→cyan." +emits: [] +has_state: false +framework: react +variant: [default] +source_repo: "claude.ai/design" +source_license: "" +source_file: "sources/frontend_designs/Ads Analytics Dashboard _standalone_.html" +--- + +## Ejemplo + +```tsx +import { FunnelChart } from '@fn_library' + +const funnel = [ + { stage: 'Impressions', value: 12_400_000 }, + { stage: 'Clicks', value: 214_807 }, + { stage: 'Sessions', value: 186_904 }, + { stage: 'Add to cart', value: 24_113 }, + { stage: 'Checkout', value: 9_642 }, + { stage: 'Conversions', value: 4_812 }, +] + +// Básico — conversión automática entre etapas + + +// Moneda + altura mayor + '$' + n.toLocaleString()} + barHeight={36} +/> + +// Umbrales custom de conversión (ej. funnel de lead gen donde >10% ya es bueno) + +``` + +## Notas + +- La barra se dimensiona como porcentaje del valor máximo (primera etapa por defecto). +- La tasa de conversión se calcula contra la **etapa anterior**, no contra el máximo. +- Extraído de un export de Claude Design (Ads Analytics Dashboard). El `AdsFunnel` original estaba hardcodeado con gradient indigo→cyan; aquí se generaliza con `barColor` y `barLabel`. +- Los tres colores semánticos del Badge (`success`/`info`/`warning`) son configurables vía `goodThreshold` y `warnThreshold`. diff --git a/frontend/functions/ui/funnel_chart.tsx b/frontend/functions/ui/funnel_chart.tsx new file mode 100644 index 00000000..12ec93da --- /dev/null +++ b/frontend/functions/ui/funnel_chart.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' +import { Stack, Group, Text, Box } from '@mantine/core' +import { Badge } from './badge' + +interface FunnelStage { + stage: string + value: number +} + +interface FunnelChartProps { + data: FunnelStage[] + /** Format function for the absolute value shown next to each stage. */ + valueFormatter?: (value: number) => string + /** Show the inter-stage conversion rate as a badge. Default true. */ + showConversion?: boolean + /** Height of each bar. Default 28. */ + barHeight?: number + /** Conversion rate (%) above which the badge is `success`. Default 30. */ + goodThreshold?: number + /** Conversion rate (%) above which the badge is `info` (below → `warning`). Default 5. */ + warnThreshold?: number + /** Label shown inside each bar. `(stage, pctOfMax) => string`. Default empty. */ + barLabel?: (stage: FunnelStage, pctOfMax: number) => string + /** Override the default gradient. Pass a single color or CSS gradient string. */ + barColor?: string + className?: string +} + +function defaultFormatter(n: number): string { + const abs = Math.abs(n) + if (abs >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B' + if (abs >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M' + if (abs >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K' + return String(Math.round(n)) +} + +const DEFAULT_BAR_GRADIENT = + 'linear-gradient(90deg, var(--mantine-primary-color-filled), color-mix(in oklab, var(--mantine-color-cyan-4) 70%, var(--mantine-primary-color-filled)))' + +function FunnelChart({ + data, + valueFormatter = defaultFormatter, + showConversion = true, + barHeight = 28, + goodThreshold = 30, + warnThreshold = 5, + barLabel, + barColor, + className, +}: FunnelChartProps) { + if (!data || data.length === 0) return null + + const max = Math.max(...data.map(d => d.value)) + + return ( + + {data.map((s, i) => { + const pct = max === 0 ? 0 : s.value / max + const convRate = i > 0 ? (s.value / (data[i - 1]!.value || 1)) * 100 : 100 + const conversionVariant = + convRate > goodThreshold ? 'success' : convRate > warnThreshold ? 'info' : 'warning' + const label = barLabel?.(s, pct) + + return ( +
+ + + {s.stage} + + + + {valueFormatter(s.value)} + + {showConversion && i > 0 && ( + + {convRate.toFixed(1)}% + + )} + + + + {label} + +
+ ) + })} +
+ ) +} + +export { FunnelChart } +export type { FunnelChartProps, FunnelStage } diff --git a/frontend/functions/ui/heatmap_grid.md b/frontend/functions/ui/heatmap_grid.md new file mode 100644 index 00000000..f4d65cda --- /dev/null +++ b/frontend/functions/ui/heatmap_grid.md @@ -0,0 +1,126 @@ +--- +name: heatmap_grid +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "HeatmapGrid(props: HeatmapGridProps): JSX.Element" +description: "Matriz rows × columns con intensidad de color proporcional al valor. Genérico — casos típicos: day×hour, cohort retention, matriz de correlación, heatmap geográfico." +tags: [heatmap, matrix, dashboard, component, ui, chart, retention, cohort] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["@mantine/core", react] +output: "Componente HeatmapGrid que renderiza una tabla de celdas coloreadas por intensidad con labels formateados." +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/heatmap_grid.tsx" +props: + - name: rows + type: "Record[]" + required: true + description: "Filas de datos. Cada objeto contiene el label de fila (bajo rowKey) y los valores (bajo las keys de columns)." + - name: rowKey + type: "string" + required: true + description: "Key en cada row donde está el label de la fila (ej. 'day', 'cohort')." + - name: columns + type: "HeatmapColumn[]" + required: true + description: "Columnas a visualizar, en orden. Cada una: { key, label? }." + - name: valueFormatter + type: "(v: number) => string" + required: false + description: "Formateador del valor numérico. Default: int o 2 decimales." + - name: rowLabelFormatter + type: "(row: Record) => string" + required: false + description: "Formatear el label de fila. Default: String(row[rowKey])." + - name: tooltip + type: "(row, column, value) => string" + required: false + description: "Generador del tooltip (title HTML nativo)." + - name: cellLabelThreshold + type: "number" + required: false + description: "Mostrar el valor dentro de la celda solo si |v| ≥ threshold. Default: siempre mostrar." + - name: cellSize + type: "number" + required: false + description: "Tamaño de cada celda en px. Default 22." + - name: colLabelEvery + type: "number" + required: false + description: "Mostrar label de columna cada N columnas (grids densos). Default 1." + - name: intensityRange + type: "[number, number]" + required: false + description: "Min/max % del baseColor aplicado vía color-mix. Default [6, 84]." + - name: baseColor + type: "string" + required: false + description: "Color base. Default 'var(--mantine-primary-color-filled)'." +emits: [] +has_state: false +framework: react +variant: [default] +source_repo: "claude.ai/design" +source_license: "" +source_file: "sources/frontend_designs/Ads Analytics Dashboard _standalone_.html" +--- + +## Ejemplo + +```tsx +import { HeatmapGrid } from '@fn_library' + +// CTR por día × hora (caso del export original) +const hours = Array.from({ length: 24 }, (_, i) => ({ key: 'h' + i, label: String(i).padStart(2, '0') })) +const rows = [ + { day: 'Mon', h0: 0.8, h1: 0.7, /* ... */ h23: 1.4 }, + { day: 'Tue', h0: 0.9, h1: 0.8, /* ... */ h23: 1.3 }, + // ... +] + + v.toFixed(2)} + cellLabelThreshold={1.7} + tooltip={(r, c, v) => `${r.day} ${c.label}:00 — CTR ${v}%`} +/> + +// Cohort retention (semana 0-12) +const cohortCols = Array.from({ length: 13 }, (_, i) => ({ key: 'w' + i, label: 'W' + i })) + + v.toFixed(0) + '%'} + intensityRange={[10, 90]} +/> + +// Matriz de correlación + ({ key: v, label: v }))} + valueFormatter={(v) => v.toFixed(2)} + cellSize={40} + baseColor="var(--mantine-color-cyan-6)" +/> +``` + +## Notas + +- El color de cada celda es `color-mix(in oklab, baseColor N%, transparent)` donde N interpola linealmente entre `intensityRange[0]` y `intensityRange[1]` según el valor normalizado en `[min, max]`. +- El color del texto en la celda cambia automáticamente a blanco si `v > mid` (mid-point del rango) para contraste. +- Para grids densos (ej. 24 horas × 7 días) usar `colLabelEvery={2}` o mayor para que los headers no se solapen. +- Extraído de un export de Claude Design (Ads Analytics Dashboard). El `Heatmap` original hardcodeaba `h0..h23`; aquí se generaliza aceptando cualquier array de columnas. diff --git a/frontend/functions/ui/heatmap_grid.tsx b/frontend/functions/ui/heatmap_grid.tsx new file mode 100644 index 00000000..e3ef960d --- /dev/null +++ b/frontend/functions/ui/heatmap_grid.tsx @@ -0,0 +1,153 @@ +import * as React from 'react' +import { Box } from '@mantine/core' + +interface HeatmapColumn { + key: string + label?: string +} + +interface HeatmapGridProps { + /** Row data. Each row is an object that contains values under the column keys and the row label under `rowKey`. */ + rows: Record[] + /** Key in each row that holds the row label (e.g. 'day', 'cohort'). */ + rowKey: string + /** Columns to visualize, in order. Each entry has the value key and an optional display label. */ + columns: HeatmapColumn[] + /** Formatter for numeric values inside cells and tooltip. */ + valueFormatter?: (v: number) => string + /** Formatter for row labels (left column). Defaults to String(row[rowKey]). */ + rowLabelFormatter?: (row: Record) => string + /** Tooltip text generator (native HTML title). */ + tooltip?: (row: Record, column: HeatmapColumn, value: number) => string + /** Render the numeric label inside the cell only if |value| ≥ threshold. Default: always render. */ + cellLabelThreshold?: number + /** Cell size in px. Default 22. */ + cellSize?: number + /** Render a column header label every N columns (keeps the grid compact for hourly grids). Default 1. */ + colLabelEvery?: number + /** Min/Max percent of primary color applied via color-mix. Default [6, 84]. */ + intensityRange?: [number, number] + /** Override the base color. Default is `var(--mantine-primary-color-filled)`. */ + baseColor?: string + className?: string +} + +function defaultValueFormatter(v: number): string { + return Number.isInteger(v) ? String(v) : v.toFixed(2) +} + +function HeatmapGrid({ + rows, + rowKey, + columns, + valueFormatter = defaultValueFormatter, + rowLabelFormatter, + tooltip, + cellLabelThreshold, + cellSize = 22, + colLabelEvery = 1, + intensityRange = [6, 84], + baseColor = 'var(--mantine-primary-color-filled)', + className, +}: HeatmapGridProps) { + const { min, max, mid } = React.useMemo(() => { + const vals: number[] = [] + for (const row of rows) { + for (const col of columns) { + const n = Number(row[col.key]) + if (!isNaN(n)) vals.push(n) + } + } + if (vals.length === 0) return { min: 0, max: 0, mid: 0 } + const mn = Math.min(...vals) + const mx = Math.max(...vals) + return { min: mn, max: mx, mid: (mn + mx) / 2 } + }, [rows, columns]) + + const [lo, hi] = intensityRange + + function cellBg(v: number): string { + if (max === min) return `color-mix(in oklab, ${baseColor} ${lo}%, transparent)` + const t = (v - min) / (max - min) + const pct = lo + t * (hi - lo) + return `color-mix(in oklab, ${baseColor} ${pct.toFixed(0)}%, transparent)` + } + + return ( + + + + + + ) + })} + + + + {rows.map((row, rowIdx) => { + const label = rowLabelFormatter ? rowLabelFormatter(row) : String(row[rowKey] ?? '') + return ( + + + {columns.map(col => { + const raw = Number(row[col.key]) + const v = isNaN(raw) ? 0 : raw + const showLabel = + cellLabelThreshold == null ? true : Math.abs(v) >= cellLabelThreshold + const title = tooltip + ? tooltip(row, col, v) + : `${label} · ${col.label ?? col.key} — ${valueFormatter(v)}` + return ( + + ) + })} + + ) + })} + +
+ {columns.map((col, i) => { + if (colLabelEvery > 1 && i % colLabelEvery !== 0) return + const span = Math.min(colLabelEvery, columns.length - i) + return ( + + {col.label ?? col.key} +
+ {label} + mid ? 'white' : 'var(--mantine-color-dimmed)', + }} + > + {showLabel ? valueFormatter(v) : ''} +
+
+ ) +} + +export { HeatmapGrid } +export type { HeatmapGridProps, HeatmapColumn } diff --git a/frontend/functions/ui/index.ts b/frontend/functions/ui/index.ts index 906cf6c1..c1df82a1 100644 --- a/frontend/functions/ui/index.ts +++ b/frontend/functions/ui/index.ts @@ -36,7 +36,11 @@ export type { Series } from './chart_container' // Data export { DataTable } from './data_table' -export type { DataTableProps, ColumnDef } from './data_table' +export type { DataTableProps, ColumnDef, DataTableDensity } from './data_table' +export { FunnelChart } from './funnel_chart' +export type { FunnelChartProps, FunnelStage } from './funnel_chart' +export { HeatmapGrid } from './heatmap_grid' +export type { HeatmapGridProps, HeatmapColumn } from './heatmap_grid' // Mantine Provider export { FnMantineProvider } from './mantine_provider'