/** * scaffold-dashboard-primitives/generate.ts — Build the editable dashboard * primitives + i18n locale stubs. Pure function (no I/O). * * Theme-compliance contract: every primitive uses ONLY SmartStack tokens * (var(--bg-card), var(--dataviz-*), var(--kpi-*), var(--chart-*), …) — zero * hardcoded Tailwind color classes. Charts read the categorical palette from * the CSS vars at runtime via useDatavizPalette (Recharts needs real color * strings in SVG fills, not var() refs). The companion test asserts this. * * The package's own dashboard components (KpiCard/ChartContainer/~25 Recharts * comps) are INTERNAL and not exported — so, like scaffold-ui-primitives, we * emit local editable equivalents the developer owns. */ import type { GeneratedFile, ScaffoldDashboardPrimitivesInput } from './types.js'; const HEADER = `/* AUTO-GENERATED — re-run scaffold-dashboard-primitives to refresh. Add @customised at the top to opt out. */`; const LOCALES = ['fr', 'en', 'it', 'de'] as const; const I18N_STRINGS: Record>> = { fr: { dashboard: { loading: 'Chargement…', error: 'Erreur de chargement', empty: 'Rien à afficher', noData: '—', unknownWidget: 'Widget non supporté' } }, en: { dashboard: { loading: 'Loading…', error: 'Failed to load', empty: 'Nothing to show', noData: '—', unknownWidget: 'Unsupported widget' } }, it: { dashboard: { loading: 'Caricamento…', error: 'Errore di caricamento', empty: 'Niente da mostrare', noData: '—', unknownWidget: 'Widget non supportato' } }, de: { dashboard: { loading: 'Wird geladen…', error: 'Ladefehler', empty: 'Nichts anzuzeigen', noData: '—', unknownWidget: 'Nicht unterstütztes Widget' } }, }; /** Shared TS contract — the single source of truth for widget config + data, * imported by the primitives AND by the generated DashboardPage. */ function typesModule(): string { return `${HEADER} export type DashboardWidgetType = 'kpi' | 'counter' | 'chart-line' | 'chart-bar' | 'chart-area' | 'chart-pie' | 'list'; export interface DashboardWidget { /** Stable key — also the data key the page maps a result onto. */ key: string; /** Human label (already translated by the page). */ label: string; type: DashboardWidgetType; /** 1-12 grid span. Defaults per type (kpi 3, charts 6, list 12). */ col?: number; /** Data source path relative to the dashboard endpoint, e.g. 'widgets/active'. */ endpoint?: string; /** Permission gating the widget's visibility. */ permission?: string; /** BA-declared hints (informational — the backend owns the aggregation math). */ entity?: string; aggregation?: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string; /** bar/area charts: stack the series instead of grouping them. */ stacked?: boolean; /** kpi/counter: render the mini trend sparkline when the result carries points. */ sparkline?: boolean; } /** A point on a line/bar chart: a label + one or more numeric series. */ export interface ChartPoint { label: string; [series: string]: number | string; } /** A slice of a pie chart. */ export interface ChartSlice { name: string; value: number; } export interface ListColumn { key: string; label: string; } /** The data a single widget renders. The backend returns one of these per * widget key (kpi → value/delta, chart-line/bar → points, chart-pie → slices, * list → rows/columns). Absent fields render the muted "no data" placeholder. */ export interface WidgetResult { value?: number | string; delta?: number; unit?: string; points?: ChartPoint[]; slices?: ChartSlice[]; rows?: Record[]; columns?: ListColumn[]; } `; } /** Reads --dataviz-1..8 from the document root so charts follow the theme * (and re-reads on a .dark class toggle). */ function useDatavizPaletteModule(): string { return `${HEADER} import { useEffect, useState } from 'react'; // Palette resolution — the THEME always wins. Recharts needs real color strings // in SVG fills (it cannot resolve var()), so the tokens are read at runtime, in // this order: // // 1. --dataviz-cat-1..12 the LIVE categorical palette the @atlashub/smartstack // theme runtime writes on from the tenant's UI // configuration (ThemeContext.applyDataViz). This is the // authoritative source whenever the app runs on the // platform theme: change the theme in the admin UI and // the charts follow immediately. // 2. --dataviz-1..8 the palette scaffold-theme bakes into src/index.css // (derived from the PRD accent). Used by apps that do // not run the platform theme runtime. // 3. --color-accent-* the accent ramp — also written live by the theme // runtime — when a project's index.css predates the // dataviz block (or carries /* @customised */, which // makes scaffold-theme skip it). Monochrome, but still // the app's own colors rather than foreign hues. // 4. LAST_RESORT no SmartStack theme readable at all (SSR / bare page). // // Levels 3-4 warn once in the console: they mean the theme is incomplete, and // audit DEV-UI-036 / ui-polish R28 flag the same gap on the project. const CATEGORICAL_LENGTH = 12; const ACCENT_SHADES = ['600', '400', '700', '300', '500']; const LAST_RESORT = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4', '#ef4444', '#84cc16']; type PaletteSource = 'platform' | 'scaffold' | 'accent' | 'last-resort'; let warned = false; function warnOnce(source: PaletteSource): void { if (warned || source === 'platform' || source === 'scaffold') return; warned = true; console.warn( '[dashboard] No categorical palette on :root (--dataviz-cat-1..12 from the platform ' + 'theme, nor --dataviz-1..8 from scaffold-theme) — charts fall back to ' + (source === 'accent' ? 'the theme accent ramp' : 'a built-in palette') + '. Re-run scaffold-theme to emit the --dataviz-* / --chart-* / --kpi-* block ' + '(if src/index.css is marked @customised the scaffolder skips it — paste the block in by hand).', ); } function readVars(cs: CSSStyleDeclaration, name: (i: number) => string, count: number): string[] { const out: string[] = []; for (let i = 1; i <= count; i++) { const v = cs.getPropertyValue(name(i)).trim(); if (v) out.push(v); } return out; } function readPalette(): string[] { const cs = getComputedStyle(document.documentElement); const platform = readVars(cs, (i) => '--dataviz-cat-' + i, CATEGORICAL_LENGTH); if (platform.length) return platform; const scaffolded = readVars(cs, (i) => '--dataviz-' + i, 8); if (scaffolded.length) return scaffolded; const accent = ACCENT_SHADES .map((shade) => cs.getPropertyValue('--color-accent-' + shade).trim()) .filter(Boolean); if (accent.length) { warnOnce('accent'); return accent; } warnOnce('last-resort'); return LAST_RESORT; } export function useDatavizPalette(): string[] { const [palette, setPalette] = useState(LAST_RESORT); useEffect(() => { if (typeof window === 'undefined') return; const read = () => setPalette(readPalette()); read(); // Re-read when the theme changes: the .dark toggle flips the class, and the // platform theme runtime (ThemeContext) writes every token as an INLINE // style on — watching the class alone would miss a live theme switch. const obs = new MutationObserver(read); obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] }); return () => obs.disconnect(); }, []); return palette; } `; } function kpiCardComponent(): string { return `${HEADER} import { useTranslation } from 'react-i18next'; import { ArrowDown, ArrowUp } from 'lucide-react'; import type { DashboardWidget, WidgetResult } from './types'; import { Sparkline } from './Sparkline'; export interface KpiCardProps { widget: DashboardWidget; data?: WidgetResult; isLoading?: boolean; error?: boolean; } export function KpiCard({ widget, data, isLoading, error }: KpiCardProps) { const { t } = useTranslation('common'); const value = data?.value; const delta = data?.delta; const hasValue = value !== undefined && value !== null; const showTrend = widget.type === 'counter' && typeof delta === 'number' && delta !== 0; const up = (delta ?? 0) > 0; return (
{widget.label}
{isLoading ? (
) : error || !hasValue ? (
{t('dashboard.noData', { defaultValue: '—' })}
) : (
{String(value)} {data?.unit ? {data.unit} : null} {showTrend ? ( {up ? : } {Math.abs(delta as number)}% ) : null}
)} {widget.sparkline && !isLoading && !error && (data?.points?.length ?? 0) > 1 ? ( ) : null}
); } `; } function chartCardComponent(): string { return `${HEADER} import { useTranslation } from 'react-i18next'; import { ResponsiveContainer, LineChart, Line, BarChart, Bar, AreaChart, Area, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, } from 'recharts'; import type { DashboardWidget, WidgetResult } from './types'; import { useDatavizPalette } from './useDatavizPalette'; export interface ChartCardProps { widget: DashboardWidget; data?: WidgetResult; isLoading?: boolean; error?: boolean; } export function ChartCard({ widget, data, isLoading, error }: ChartCardProps) { const { t } = useTranslation('common'); const palette = useDatavizPalette(); const variant = widget.type === 'chart-bar' ? 'bar' : widget.type === 'chart-pie' ? 'pie' : widget.type === 'chart-area' ? 'area' : 'line'; const stackId = widget.stacked ? '1' : undefined; const points = data?.points ?? []; const slices = data?.slices ?? []; const seriesKeys = points.length ? Object.keys(points[0]).filter((k) => k !== 'label') : []; const empty = variant === 'pie' ? slices.length === 0 : points.length === 0; const axisProps = { stroke: 'var(--chart-axis)', fontSize: 12, tickLine: false }; const tooltipStyle = { backgroundColor: 'var(--chart-tooltip-bg)', color: 'var(--chart-tooltip-text)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-card)', }; return (
{widget.label}
{isLoading ? (
) : error || empty ? (
{t('dashboard.noData', { defaultValue: 'No data' })}
) : (
{variant === 'pie' ? ( {slices.map((_, i) => )} ) : variant === 'bar' ? ( {seriesKeys.length > 1 ? : null} {seriesKeys.map((k, i) => )} ) : variant === 'area' ? ( {seriesKeys.length > 1 ? : null} {seriesKeys.map((k, i) => )} ) : ( {seriesKeys.length > 1 ? : null} {seriesKeys.map((k, i) => )} )}
)}
); } `; } /** * Sparkline — the ONE other sanctioned recharts location besides ChartCard * (DEV-UI-036 / ui-polish R28 whitelist both): a 40px axis-less area trend * inside a KpiCard. First series of the widget result's points. */ function sparklineComponent(): string { return `${HEADER} import { ResponsiveContainer, AreaChart, Area } from 'recharts'; import type { ChartPoint } from './types'; import { useDatavizPalette } from './useDatavizPalette'; export interface SparklineProps { points: ChartPoint[]; } export function Sparkline({ points }: SparklineProps) { const palette = useDatavizPalette(); const series = points.length ? Object.keys(points[0]).filter((k) => k !== 'label')[0] : undefined; if (!series) return null; return ( ); } `; } function listWidgetComponent(): string { return `${HEADER} import { useTranslation } from 'react-i18next'; import type { DashboardWidget, WidgetResult } from './types'; export interface ListWidgetProps { widget: DashboardWidget; data?: WidgetResult; isLoading?: boolean; error?: boolean; } export function ListWidget({ widget, data, isLoading, error }: ListWidgetProps) { const { t } = useTranslation('common'); const rows = data?.rows ?? []; const columns = data?.columns ?? (rows.length ? Object.keys(rows[0]).map((k) => ({ key: k, label: k })) : []); return (
{widget.label}
{isLoading ? (
{[0, 1, 2].map((i) =>
)}
) : error || rows.length === 0 ? (
{t('dashboard.empty', { defaultValue: 'Nothing to show' })}
) : (
{columns.map((c) => )} {rows.map((row, ri) => ( {columns.map((c) => )} ))}
{c.label}
{String(row[c.key] ?? '')}
)}
); } `; } function dashboardGridComponent(): string { return `${HEADER} import type { ReactNode } from 'react'; export interface DashboardGridProps { children: ReactNode; } /** * 12-column responsive grid. Each child sets its own span (WidgetRenderer does * this from widget.col); on small screens the grid collapses to a single column * so widgets stack full-width. */ export function DashboardGrid({ children }: DashboardGridProps) { return
{children}
; } `; } function widgetRendererComponent(): string { return `${HEADER} import { useTranslation } from 'react-i18next'; import type { ReactNode } from 'react'; import type { DashboardWidget, WidgetResult } from './types'; import { KpiCard } from './KpiCard'; import { ChartCard } from './ChartCard'; import { ListWidget } from './ListWidget'; export interface WidgetRendererProps { widget: DashboardWidget; data?: WidgetResult; isLoading?: boolean; error?: boolean; /** Drill-down: makes the whole widget an affordance opening the backing * list view (the page resolves the route + preset URL-state params). */ onOpen?: () => void; } const DEFAULT_COL: Record = { kpi: 3, counter: 3, 'chart-line': 6, 'chart-bar': 6, 'chart-area': 6, 'chart-pie': 6, list: 12, }; /** * Renders ONE typed widget. The page owns the data fetch and passes * data[widget.key]; an unknown type or absent data degrades to a muted * placeholder (never throws). The grid span comes from widget.col. */ export function WidgetRenderer({ widget, data, isLoading, error, onOpen }: WidgetRendererProps) { const { t } = useTranslation('common'); const span = Math.min(Math.max(widget.col ?? DEFAULT_COL[widget.type] ?? 6, 1), 12); let body: ReactNode; switch (widget.type) { case 'kpi': case 'counter': body = ; break; case 'chart-line': case 'chart-bar': case 'chart-area': case 'chart-pie': body = ; break; case 'list': body = ; break; default: body = (
{t('dashboard.unknownWidget', { defaultValue: 'Unsupported widget' })}
); } if (onOpen) { body = (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(); } }} className="cursor-pointer rounded-[var(--radius-card)] transition-shadow hover:shadow-[var(--shadow-card)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-accent-500)]" > {body}
); } return
{body}
; } `; } function localeFile(locale: typeof LOCALES[number]): GeneratedFile { return { path: `src/i18n/locales/${locale}/common.json`, content: JSON.stringify(I18N_STRINGS[locale], null, 2) + '\n', strategy: 'deep-merge-json', }; } export function generate(_spec: ScaffoldDashboardPrimitivesInput): GeneratedFile[] { return [ { path: 'src/components/dashboard/types.ts', content: typesModule(), strategy: 'overwrite' }, { path: 'src/components/dashboard/useDatavizPalette.ts', content: useDatavizPaletteModule(), strategy: 'overwrite' }, { path: 'src/components/dashboard/KpiCard.tsx', content: kpiCardComponent(), strategy: 'overwrite' }, { path: 'src/components/dashboard/ChartCard.tsx', content: chartCardComponent(), strategy: 'overwrite' }, { path: 'src/components/dashboard/Sparkline.tsx', content: sparklineComponent(), strategy: 'overwrite' }, { path: 'src/components/dashboard/ListWidget.tsx', content: listWidgetComponent(), strategy: 'overwrite' }, { path: 'src/components/dashboard/DashboardGrid.tsx', content: dashboardGridComponent(), strategy: 'overwrite' }, { path: 'src/components/dashboard/WidgetRenderer.tsx', content: widgetRendererComponent(), strategy: 'overwrite' }, ...LOCALES.map(localeFile), ]; }