PageSourceSearch

https://pltt.ai/session-dashboard.js

js pltt.ai collected 2026-10-05 15:19:57 UTC 59,087 bytes, 700 lines download raw bytes

1// Generated by scripts/sync-session-dashboard.mjs. Do not edit.
2(function(){
3// Session dashboard charts: shared by React and the sandbox/offline runtime.
4// No browser or framework dependencies. The platform's chart defaults are separate.
5const THEMES = {
6  signal: ['#FF4B5E', '#FF3F64', '#FF8367'], tide: ['#2E9BFF', '#0C6CD8', '#69C1FF'],
7  mint: ['#1FBF6B', '#0FA85A', '#6CE0A0'], iris: ['#8B6CF5', '#6A4DF0', '#C08CFF'],
8  amber: ['#FFB02E', '#FF9500', '#FFD466'],
9}
10const TYPES = ['line', 'area', 'sparkline', 'combo', 'bar', 'stacked-bar', 'pie', 'donut', 'ring', 'concentric', 'gauge', 'heatmap', 'scatter', 'radar', 'range', 'boxplot', 'before-after', 'network', 'bubble', 'sankey', 'funnel', 'geo']
11const fail = message => { throw new Error(message) }
12const finite = n => typeof n === 'number' && Number.isFinite(n)
13const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
14const fade = (hex, opacity) => `rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},${opacity})`
15const UI = 'Session Dashboard Inter, Inter, system-ui, sans-serif', MONO = 'Session Dashboard JetBrains Mono, JetBrains Mono, monospace'
16const DIRS = { down: { x: 0, y: 0, x2: 0, y2: 1 }, up: { x: 0, y: 1, x2: 0, y2: 0 }, right: { x: 0, y: 0, x2: 1, y2: 0 } }
17const gradient = (a, b, along = 'down') => ({ type: 'linear', ...DIRS[along], colorStops: [{ offset: 0, color: a }, { offset: 1, color: b }] })
18
19function dashboardTheme(canvas = 'light', theme = 'signal') {
20  const dark = canvas === 'dark', [accent, accentA, accentB] = THEMES[theme] || THEMES.signal
21  return {
22    dark, accent, accentA, accentB, accentSoft: fade(accent, .14),
23    palette: ['#6A6EF3', '#22C04A', '#FF5668', '#69C1FF', '#F0A35E', '#FA78F0', '#FCCC00', '#D7F205'],
24    seq: ['#BFE3FF', '#8FCBFF', '#69C1FF', '#2E9BFF', '#0C6CD8', '#08498F'],
25    pos: '#22C04A', neg: '#FF4B5E', warn: '#FCCC00', info: '#1E90FF', neutral: '#8A8C92',
26    surface: dark ? '#0B0C0E' : '#FFFFFF', surface2: dark ? '#141517' : '#F4F6F9', surface3: dark ? '#1B1D20' : '#ECEFF3',
27    ink: dark ? '#FFFFFF' : '#0E1620', inkMuted: dark ? '#9A9CA3' : '#5C6470', muted: dark ? '#9A9CA3' : '#5C6470',
28    dim: dark ? '#9A9CA3' : '#5C6470', grid: dark ? 'rgba(255,255,255,.14)' : 'rgba(16,22,30,.12)',
29    tooltip: { backgroundColor: '#23272B', borderWidth: 0, padding: [8, 12], textStyle: { color: '#F
29FFFFF', fontFamily: MONO, fontSize: 12 }, extraCssText: 'border-radius:9px;box-shadow:0 8px 24px rgba(0,0,0,.30)' },
30  }
31}
32
33// The HTML contract extends existing data-chart specs; doc/deck ChartSpec stays intact.
34function validateDashboardChart(s) {
35  if (!s || !TYPES.includes(s.type)) fail('Unsupported dashboard chart type')
36  for (const key of ['title', 'unit']) if (s[key] !== undefined && (typeof s[key] !== 'string' || s[key].length > 500)) fail(`Invalid ${key}`)
37  for (const key of ['x', 'y', 'series', 'items', 'nodes', 'links', 'points']) {
38    if (s[key] !== undefined && (!Array.isArray(s[key]) || s[key].length > 500)) fail(`Invalid or oversized ${key}`)
39  }
40  const series = s.series || [], items = s.items || []
41  if ([...series, ...items, ...(s.points || []), ...(s.links || []), ...(s.nodes || [])].some(it => !it || typeof it !== 'object' || Array.isArray(it))) fail('Invalid data entry')
42  if (s.x?.some(v => typeof v !== 'string') || s.y?.some(v => typeof v !== 'string')) fail('Category labels must be text')
43  if (s.type === 'heatmap' && s.y && s.y.length !== series.length) fail('Heatmap labels must match rows')
44  if (s.type === 'radar' && (s.x?.length || 0) < 3) fail('Radar requires at least three categories')
45  if (series.length > 20) fail('Use at most 20 series')
46  if (['ring', 'gauge'].includes(s.type)) {
47    if (!finite(s.value) || !finite(s.max ?? 100) || (s.max ?? 100) <= 0 || s.value < 0 || s.value > (s.max ?? 100)) fail('Value must be between zero and max')
48  } else if (['pie', 'donut', 'funnel', 'concentric'].includes(s.type)) {
49    if (!series.length) fail('No data available')
50    if (!series.every(it => finite(it.value) && it.value >= 0)) fail('Values must be non-negative numbers')
51    if (s.type === 'concentric' && !series.every(it => finite(it.max ?? 100) && (it.max ?? 100) > 0 && it.value <= (it.max ?? 100))) fail('Ring values must be between zero and max')
52  } else if (['network', 'sankey'].includes(s.type)) {
53    if (!s.links?.length) fail('No data available')
54    if (!s.links.every(l => typeof l.source === 'string' && l.source && typeof l.target === 'string' && l.target && finite(l.value) && l.value >= 0)) fail('Links require source, target and non-negative value')
55    const names = new Set(s.nodes?.map(n => n.name) || s.links.flatMap(l => [l.source, l.target]))
56    if ([...names].some(n => typeof n !== 'string' || !n) || (s.nodes && names.size !== s.nodes.length)) fail('Node names must be unique non-empty text')
57    if (names.size > 500 || s.links.some(l => !names.has(l.source) || !names.has(l.target))) fail('Link references an unknown node')
58    if (s.type === 'sankey') {
59      const edges = new Map([...names].map(n => [n, []])), visited = new Set(), pending = new Set()
60      s.links.forEach(l => edges.get(l.source).push(l.target))
61      const visit = n => { if (pending.has(n)) fail('Sankey links must not contain cycles'); if (visited.has(n)) return; pending.add(n); edges.get(n).forEach(visit); pending.delete(n); visited.add(n) }
62      names.forEach(visit)
63    }
64  } else if (['range', 'boxplot', 'before-after'].includes(s.type)) {
65    if (!items.length) fail('No data available')
66    for (const it of items) {
67      if (s.type === 'range' && (!finite(it.low) || !finite(it.high) || it.low > it.high)) fail('Range requires low <= high')
68      if (s.type === 'before-after' && (!finite(it.before) || !finite(it.after))) fail('Before/after requires numeric values')
69      if (s.type === 'boxplot' && (!Array.isArray(it.values) || !it.values.length || it.values.length > 500 || !it.values.every(finite))) fail('Box plot requires numeric observations')
70    }
71  } else if (['geo', 'bubble'].includes(s.type)) {
72    if (!s.points?.length) fail('No data available')
73    if (!s.points.every(p => finite(p.value) && p.value >= 0 && (s.type === 'bubble' || (finite(p.lng) && finite(p.lat) && Math.abs(p.lng) <= 180 && Math.abs(p.lat) <= 90)))) fail('Invalid point value or longitude/latitude')
74  } else {
75    if (!series.length || !series.some(it => Array.isArray(it.data) && it.data.some(v =>
75 s.type === 'scatter' ? Array.isArray(v) : finite(v)))) fail('No data available')
76    for (const it of series) {
77      if (!Array.isArray(it.data) || it.data.length > 500) fail('Invalid series data')
78      if (s.type === 'scatter') {
79        if (!it.data.every(p => Array.isArray(p) && p.length === 2 && p.every(finite))) fail('Scatter requires numeric x/y pairs')
80      } else {
81        if (!s.x?.length || it.data.length !== s.x.length || !it.data.every(v => v === null || finite(v))) fail('Series values must match categories')
82        if (s.type === 'combo' && !['line', 'bar'].includes(it.type)) fail('Combo series requires type line or bar')
83      }
84    }
85  }
86  return s
87}
88
89function buildDashboardOption(spec, t = dashboardTheme()) {
90  const s = validateDashboardChart(spec), type = s.type, series = s.series || [], unit = s.unit ? ` ${s.unit}` : ''
91  const tone = v => ({ pos: t.pos, neg: t.neg, warn: t.warn, info: t.info, neutral: t.neutral, accent: t.accent })[v]
92  const ink = { color: t.inkMuted, fontFamily: MONO, fontSize: 10 }
93  // Inter for words, JetBrains Mono for numbers (the reference rule).
94  const axis = (categories) => ({ type: categories ? 'category' : 'value', ...(categories ? { data: categories } : {}), axisLabel: { ...(categories ? { ...ink, fontFamily: UI, fontSize: 11 } : ink), hideOverlap: true }, axisLine: { show: !!categories, lineStyle: { color: t.grid } }, axisTick: { show: false }, splitLine: { show: !categories, lineStyle: { color: t.grid, type: 'dashed' } } })
95  const base = {
96    color: t.palette, backgroundColor: 'transparent', animation: false,
97    textStyle: { fontFamily: UI, color: t.ink },
98    title: s.title ? { text: String(s.title), left: 0, top: 0, textStyle: { fontSize: 13, fontWeight: 600, color: t.inkMuted } } : undefined,
99    tooltip: { ...t.tooltip, trigger: 'item', valueFormatter: v => `${v ?? '—'}${unit}`, confine: true },
100    legend: { show: series.length > 1, bottom: 0, icon: 'circle', itemWidth: 9, itemHeight: 9, textStyle: ink },
101    grid: { left: 8, right: 16, top: s.title ? 42 : 18, bottom: series.length > 1 ? 32 : 8, containLabel: true },
102  }
103  if (['line', 'area', 'sparkline', 'combo', 'bar', 'stacked-bar'].includes(type)) {
104    const horizontal = s.orientation === 'horizontal', spark = type === 'sparkline'
105    base.xAxis = axis(horizontal ? undefined : s.x); base.yAxis = axis(horizontal ? s.x : undefined)
106    base.tooltip.trigger = 'axis'
107    const along = horizontal ? 'right' : 'up', suffix = s.unit && s.unit.length <= 3 ? s.unit : ''
108    if (horizontal) { base.yAxis.inverse = true; base.yAxis.axisLabel.width = 120; base.yAxis.axisLabel.overflow = 'truncate' }
109    base.series = series.map((it, i) => {
110      const bar = ['bar', 'stacked-bar'].includes(type) || (type === 'combo' && it.type === 'bar')
111      const col = tone(it.tone) || (series.length === 1 ? t.accent : t.palette[i % 8])
112      const data = it.data.map((v, j) => bar && s.highlight !== undefined ? { value: v, itemStyle: { color: j === s.highlight ? col : t.surface3 } } : v)
113      // Lone category series: accent gradient along the bar + value labels; ramp/highlight/tone stay opt-in.
114      const plain = bar && series.length === 1 && type !== 'stacked-bar' && !tone(it.tone) && s.highlight === undefined && s.variant !== 'ramp'
115      if (plain && it.data.length <= 12 && !spark) (horizontal ? base.xAxis : base.yAxis).boundaryGap = [0, horizontal ? '18%' : '15%']
116      return { name: it.name, type: bar ? 'bar' : 'line', data, stack: type === 'stacked-bar' ? 'total' : undefined,
117        barMaxWidth: 48, showSymbol: it.data.length === 1, symbolSize: 7, connectNulls: false,
118        label: plain && it.data.length <= 12 && !spark ? { show: true, position: horizontal ? 'right' : 'top', fontFamily: MONO, fontSize: 11, color: t.ink, formatter: p => `${p.value}${suffix}` } : undefined,
119        lineStyle: { width: 2.4, color: col }, itemStyle: { color: bar && s.variant === 'ramp' ? gradient(t.info, t.pos, along) : plain ? gradient(t.accentA, t.accentB, along) : col, borderRadius: bar ? (horizontal ? [0, 5, 5, 0] : [5, 5, 0, 0]) : undefined },
120        areaStyle: ['area', 'sparkline'].includes(type) ? { color: gradient(fade(col, .28), fade(col, 0)) } : undefined,
121        markPoint: s.peak && !bar ? { data: [{ type: 'max' }], symbol: 'circle', symbolSize: 8, label: { show: true, position: 'top', color: '#F
121FFFFF', backgroundColor: '#23272B', borderRadius: 6, padding: [5, 8], formatter: p => `${p.value}${unit}` } } : undefined,
122      }
123    })
124    if (spark) { base.xAxis.show = false; base.yAxis.show = false; base.grid = { left: 0, right: 0, top: 4, bottom: 4 }; base.legend.show = false; base.title = undefined }
125  } else if (['pie', 'donut', 'funnel'].includes(type)) {
126    const total = series.reduce((sum, it) => sum + it.value, 0)
127    base.series = [{ type: type === 'funnel' ? 'funnel' : 'pie', radius: type === 'donut' ? ['58%', '82%'] : '75%', center: ['50%', '48%'], top: s.title ? 40 : 10, bottom: 40, sort: 'none', gap: 4,
128      data: series.map((it, i) => ({ name: it.name, value: it.value, itemStyle: { color: tone(it.tone) || t.palette[i % 8] } })),
129      itemStyle: { borderWidth: 2, borderColor: t.surface, borderRadius: 4 }, label: { show: type !== 'donut', color: t.inkMuted, formatter: '{b}: {c}' } }]
130    base.legend.show = true
131    if (type === 'donut') {
132      // Percent shares sum to 100 — meaningless as a centre; show the leading share instead (reference heroDonut: number + caption).
133      const top = series.reduce((a, b) => b.value > a.value ? b : a), pct = s.unit === '%'
134      base.graphic = [
135        { type: 'text', left: 'center', top: s.title ? '40%' : '36%', style: { text: pct ? `${top.value}%` : String(total), fill: t.ink, fontSize: 28, fontWeight: 800, fontFamily: MONO } },
136        { type: 'text', left: 'center', top: s.title ? '53%' : '49%', style: { text: pct ? top.name : 'total', fill: t.inkMuted, fontSize: 12, fontFamily: UI } },
137      ]
138    }
139  } else if (['ring', 'concentric', 'gauge'].includes(type)) {
140    const rings = type === 'concentric' ? series : [{ name: s.title, value: s.value, max: s.max }]
141    base.title = undefined; base.legend.show = false
142    base.series = rings.map((it, i) => ({ type: 'gauge', min: 0, max: it.max ?? 100, radius: `${Math.max(15, 88 - i * (65 / rings.length))}%`,
143      startAngle: type === 'gauge' ? 180 : 90, endAngle: type === 'gauge' ? 0 : -270,
144      axisLine: { roundCap: true, lineStyle: { width: type === 'gauge' ? 16 : 8, color: type === 'gauge' ? [[.33, t.pos], [.66, t.warn], [1, t.neg]] : [[1, t.surface3]] } },
145      progress: { show: type !== 'gauge', roundCap: true, width: 8, itemStyle: { color: tone(it.tone) || (rings.length === 1 ? t.accent : t.palette[i % 8]) } },
146      pointer: { show: type === 'gauge', length: '65%', width: 4, itemStyle: { color: t.ink } }, axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
147      detail: { show: rings.length === 1, color: t.ink, fontSize: 24, offsetCenter: [0, type === 'gauge' ? '30%' : '0%'], formatter: `{value}${unit}` },
148      title: { show: rings.length === 1, color: t.inkMuted, fontSize: 12, offsetCenter: [0, '60%'] }, data: [{ value: it.value, name: it.name }],
149    }))
150  } else if (type === 'heatmap') {
151    const values = series.flatMap(it => it.data).filter(finite)
152    base.xAxis = axis(s.x); base.yAxis = axis(s.y || series.map(it => it.name)); base.legend.show = false; base.grid.bottom = 46
153    base.visualMap = { min: Math.min(0, ...values), max: Math.max(1, ...values), orient: 'horizontal', left: 'center', bottom: 0, inRange: { color: t.seq }, textStyle: ink }
154    base.series = [{ type: 'heatmap', data: series.flatMap((it, y) => it.data.flatMap((v, x) => v === null ? [] : [[x, y, v]])), itemStyle: { borderColor: t.surface, borderWidth: 2, borderRadius: 3 }, label: { show: false } }]
155  } else if (type === 'scatter') {
156    base.xAxis = axis(); base.yAxis = axis(); base.series = series.map(it => ({ ...it, type: 'scatter', symbolSize: 10, itemStyle: { opacity: .85, borderColor: t.surface, borderWidth: 1 } }))
157  } else if (type === 'radar') {
158    base.radar = { indicator: s.x.map((name, i) => ({ name, min: Math.min(0, ...series.map(it => it.data[i] ?? 0)), max: Math.max(1, ...series.map(it => it.data[i] ?? 0)) * 1.2 })), axisName: ink, splitLine: { lineStyle: { color: t.grid } }, splitArea: { show: false } }
159    base.series = [{ type: 'radar', data: series.map(it => ({ name: it.name, value: it.data })), areaStyle: { opacity: .12 } }]
160  } else if (['range', 'before-after', 'boxplot'].includes(type)) {
161    base.xAxis = axis(s.items.map(it => it.label)); base.yAxis = axis(); base.tooltip.trigger = 'axis'
162    if (type === 'range') {
163      // Custom rectangles handle negative and zero-crossing intervals correctly.
164      base.series = [{ type: 'custom', encode: { x: 0, y: [1, 2] }, data: s.items.map((it, i) => [i, it.low, it.high]), renderItem: (_params, api) => { const a = api.coord([api.value(0), api.value(1)]), b = api.coord([api.value(0), api.value(2)]); return { type: 'rect', shape: { x: a[0] - 12, y: b[1], width: 24, height: Math.max(1, a[1] - b[1]), r: 5 }, style: { fill: t.accent } } } }]
165      base.tooltip.formatter = p => { const it = s.items[p[0]?.dataIndex ?? 0]; return `${esc(it.label)}: ${it.low} – ${it.high}${esc(unit)}` }
166    } else if (type === 'before-after') {
167      base.series = ['before', 'after'].map((key, i) => ({ name: key, type: 'bar', data: s.items.map(it => it[key]), barMaxWidth: 28, itemStyle: { color: i ? t.accent : t.info, borderRadius: [4, 4, 0, 0] } })); base.legend.show = true; base.grid.bottom = 32
168    } else {
169      base.series = [{ type: 'boxplot', data: s.items.map(it => { const a = [...it.values].sort((a, b) => a - b); const q = p => { const n = (a.length - 1) * p, lo = Math.floor(n); return a[lo] + (a[Math.ceil(n)] - a[lo]) * (n - lo) }; return [a[0], q(.25), q(.5), q(.75), a.at(-1)] }), itemStyle: { color: fade(t.accent, .15), borderColor: t.accent } }]
170    }
171  } else if (['network', 'sankey'].includes(type)) {
172    const nodes = s.nodes || [...new Set(s.links.flatMap(l => [l.source, l.target]))].map(name => ({ name }))
173    base.series = [{ type: type === 'network' ? 'graph' : 'sankey', layout: 'circular', data: nodes.map((n, i) => ({ name: n.name, itemStyle: { color: t.palette[i % 8] } })), links: s.links, roam: false, symbolSize: 26, nodeWidth: 14, nodeGap: 18, label: { show: true, color: t.ink, fontSize: 11 }, lineStyle: { color: type === 'sankey' ? 'gradient' : t.info, opacity: .4, curveness: .3 }, emphasis: { focus: 'adjacency' } }]
174  } else if (type === 'bubble') {
175    const max = Math.max(1, ...s.points.map(p => p.value))
176    base.xAxis = { ...axis(), show: false, min: -1, max: Math.max(1, s.points.length) }; base.yAxis = { ...axis(), show: false, min: -1, max: 1 }
177    base.series = [{ type: 'scatter', data: s.points.map((p, i) => ({ name: p.name, value: [i, 0, p.value], itemStyle: { color: s.variant === 'volume' ? 'transparent' : { type: 'radial', x: .35, y: .3, r: .8, colorStops: [{ offset: 0, color: '#F
177FE03D' }, { offset: .6, color: '#3DCB5B' }, { offset: 1, color: '#FF7A3D' }] }, borderColor: t.ink, borderWidth: 1 } })), symbolSize: v => Math.sqrt(v[2] / max) * 94, label: { show: true, position: 'bottom', color: t.ink, formatter: p => `${p.name}\n${p.value[2]}` } }]
178    base.tooltip.formatter = p => `${esc(p.name)}: ${p.value[2]}${esc(unit)}`
179  } else if (type === 'geo') {
180    base.geo = { map: 'session-dashboard-world', roam: false, itemStyle: { areaColor: t.surface3, borderColor: t.grid }, emphasis: { disabled: true } }
181    base.series = [{ type: 'scatter', coordinateSystem: 'geo', data: s.points.map(p => ({ name: p.name, value: [p.lng, p.lat, p.value], itemStyle: { color: tone(p.tone) || t.info } })), symbolSize: 12 }]
182    base.tooltip.formatter = p => `${esc(p.name)}: ${p.value[2]}${esc(unit)}`
183  }
184  return base
185}
186
187function chartRows(s) {
188  if (s.items) return s.items.map(it => [it.label, ...(it.values || (s.type === 'range' ? [it.low, it.high] : [it.before, it.after]))])
189  if (s.links) return s.links.map(l => [l.source, l.target, l.value])
190  if (s.points) return s.points.map(p => [p.name, ...(s.type === 'geo' ? [p.lng, p.lat] : []), p.value])
191  if (s.value !== undefined) return [[s.title || 'Value', s.value, s.max ?? 100]]
192  return (s.series || []).flatMap(it => it.value !== undefined ? [[it.name, it.value, ...(it.max !== undefined ? [it.max] : [])]] : (it.data || []).map((v, i) => [it.name, s.x?.[i] ?? i, ...(Array.isArray(v) ? v : [v])]))
193}
194
195/* ── Board layout: a 12-column packed grid ──────────────────────────────────
196 * Moved here from lib/panel-layout.ts so the server renderer and the React
197 * Data pane pack identically. Pure data + functions; panel-layout.ts re-exports.
198 * Every panel has a natural SPAN (columns × 40px rows), the template only
199 * decides role ORDER, and a 1D packer fills rows to exactly `cols`. */
200const KPI_TYPES = new Set(['kpi', 'stats', 'hero', 'ring', 'split', 'segments', 'badges', 'bullet', 'gauge', 'progress'])
201const KPI_VISUALS = new Set(['stat', 'multistat', 'kpi-strip', 'progress', 'progress-multi', 'radial', 'ring-kpi', 'gauge', 'hero', 'ring', 'split', 'segments', 'badges', 'bullet'])
202const CHART_TYPES = new Set(['line', 'area', 'bar', 'vertical-bar', 'stacked-bar', 'pie', 'donut', 'scatter', 'radar', 'treemap', 'funnel', 'heatmap', 'sankey', 'boxplot', 'range', 'before-after', 'network', 'bubble', 'combo', 'leaderboard', 'sparkline', 'concentric', 'geo'])
203const CHART_VISUALS = new Set(['sparkline', 'area', 'combo', 'bars', 'vbars', 'donut', 'pie', 'heatmap', 'scatter', 'boxplot', 'range', 'before-after', 'radar', 'treemap', 'funnel', 'stacked-bar', 'line', 'step-line', 'stacked-area', 'pct-stacked', 'waterfall', 'pareto', 'histogram', 'pictorial-bar', 'lollipop', 'dumbbell', 'slope', 'sunburst', 'rose', 'tree', 'candlestick', 'parallel', 'themeriver', 'effect-scatter', 'sankey', 'network', 'bubble', 'choropleth', 'pin-map', 'leaderboard'])
204const TABLE_TYPES = new Set(['table', 'comparison', 'master-detail', 'datafeeds', 'document', 'kanban'])
205const TABLE_VISUALS = new Set(['table', 'dense-table', 'master-detail', 'comparison', 'datafeeds', 'document', 'kanban', 'list-rich'])
206const TAKEAWAY_TYPES = new Set(['alert'])
207
208function panelRole(panel) {
209  if (panel.type === 'po') {
210    const v = panel.visual
211    if (KPI_VISUALS.has(v)) return 'kpi'
212    if (TABLE_VISUALS.has(v)) return 'table'
213    if (CHART_VISUALS.has(v)) return 'chart'
214    return 'narrative'
215  }
216  if (KPI_TYPES.has(panel.type)) return 'kpi'
217  if (TABLE_TYPES.has(panel.type)) return 'table'
218  if (CHART_TYPES.has(panel.type)) return 'chart'
219  if (TAKEAWAY_TYPES.has(panel.type)) return 'takeaway'
220  return 'narrative'
221}
222
223const LAYOUTS = {
224  analytical: ['kpi', 'chart', 'table', 'narrative', 'takeaway'],
225  executive: ['kpi', 'chart', 'narrative', 'table', 'takeaway'],
226  comparison: ['table', 'chart', 'kpi', 'narrative', 'takeaway'],
227}
228const DEFAULT_LAYOUT = 'analytical'
229const isLayoutName = v => typeof v === 'string' && v in LAYOUTS
230/** Studio templates carry the role order: executive/comparison by n
230ame, the rest analytical. */
231const layoutForTemplate = templateId => templateId === 'dashboard-executive' ? 'executive' : templateId === 'dashboard-comparison' ? 'comparison' : DEFAULT_LAYOUT
232
233// A tile strip may shrink to two thirds so it sits beside the hero (the reference's hero + ghost stats row).
234const STRIP = { gw: 12, gh: 3, minGw: 6, flow: true }
235const wide = { gw: 7, gh: 9, minGw: 5 }, half = { gw: 5, gh: 9, minGw: 4 }, broad = { gw: 8, gh: 9, minGw: 6 }, words = { gw: 6, gh: 9, minGw: 4 }
236const SPANS = {
237  stats: STRIP, kpi: STRIP, multistat: STRIP, 'kpi-strip': STRIP, 'progress-multi': STRIP,
238  hero: { gw: 12, gh: 5 },
239  ring: { gw: 3, gh: 4 }, 'ring-kpi': { gw: 3, gh: 4 }, gauge: { gw: 3, gh: 4 }, radial: { gw: 3, gh: 4 }, state: { gw: 3, gh: 4 }, stat: { gw: 3, gh: 4 },
240  split: { gw: 4, gh: 4 }, segments: { gw: 4, gh: 4 }, badges: { gw: 4, gh: 4 }, progress: { gw: 4, gh: 4 },
241  bullet: { gw: 6, gh: 5 },
242  line: wide, area: wide, bar: wide, bars: wide, 'vertical-bar': wide, vbars: wide, 'stacked-bar': wide, combo: wide, 'step-line': wide,
243  'stacked-area': wide, 'pct-stacked': wide, waterfall: wide, pareto: wide, histogram: wide, lollipop: wide, dumbbell: wide, candlestick: wide, 'pictorial-bar': wide,
244  pie: half, donut: half, funnel: half, radar: half, slope: half, rose: half, sparkline: half, 'before-after': half, leaderboard: half, concentric: half,
245  heatmap: broad, treemap: broad, scatter: broad, sankey: broad, network: broad, bubble: broad, sunburst: broad, tree: broad, boxplot: broad, range: broad, parallel: broad,
246  themeriver: broad, 'effect-scatter': broad, choropleth: broad, 'pin-map': broad, geo: broad,
247  table: broad, 'dense-table': broad, comparison: broad,
248  'master-detail': { gw: 12, gh: 10 }, kanban: { gw: 12, gh: 10 }, columns: { gw: 12, gh: 10 }, colcards: { gw: 12, gh: 10 },
249  feed: { gw: 4, gh: 9, minGw: 3 }, list: { gw: 4, gh: 9, minGw: 3 }, 'list-rich': { gw: 4, gh: 9, minGw: 3 }, datafeeds: { gw: 4, gh: 9, minGw: 3 },
250  timeline: words, document: words, approval: words, image: words, markdown: words, summary: words, code: words, text: words,
251  alert: { gw: 12, gh: 2, flow: true },
252}
253const DEFAULT_SPAN = { gw: 6, gh: 6, minGw: 4 }
254const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))
255
256function panelSpan(panel) {
257  if (panel.type === 'table') {
258    if (!Array.isArray(panel.rows)) return { gw: 12, gh: 9 } // bound `{{table:ref}}`: rows unknown until refresh
259    const wideTable = panel.headers.length >= 5
260    return { gw: wideTable ? 12 : 8, gh: clamp(panel.rows.length + 2, 6, 14), minGw: 6 }
261  }
262  if (panel.type === 'po' && (panel.visual === 'table' || panel.visual === 'dense-table')) {
263    const data = panel.data || {}
264    const rows = Array.isArray(data.rows) ? data.rows.length : 0, cols = Array.isArray(data.columns) ? data.columns.length : 0
265    return { gw: cols >= 5 ? 12 : 8, gh: rows ? clamp(rows + 2, 6, 14) : 9, minGw: 6 }
266  }
267  const key = panel.type === 'po' ? panel.visual : panel.type
268  return SPANS[key] || DEFAULT_SPAN
269}
270
271const kpiRank = panel => { const key = panel.type === 'po' ? panel.visual : panel.type; return key === 'hero' ? 0 : SPANS[key]?.flow ? 2 : 1 }
272
273function layoutGrid(panels, layout, cols = 12, session = false) {
274  if (session) return sessionGrid(panels, layout, cols)
275  const name = isLayoutName(layout) ? layout : DEFAULT_LAYOUT
276  const order = LAYOUTS[name]
277  const byRole = new Map(order.map(r => [r, []]))
278  for (const panel of panels) byRole.get(panelRole(panel)).push(panel)
279  const kpis = byRole.get('kpi')
280  kpis.sort((a, b) => kpiRank(a) - kpiRank(b))
281  const heroShared = kpis.some(p => kpiRank(p) === 0) && kpis.length > 1
282  const ordered = order.flatMap(r => byRole.get(r))
283  const scale = cols / 12
284  const cells = []
285  let row = [], used = 0, firstChartDone = false
286  const spanOf = panel => {
287    const s = panelSpan(panel), key = panel.type === 'po' ? panel.visual : panel.type
288    if (key === 'hero' && heroShared) return { ...s, gw: 4 }
289    if (name === 'executive' && panelRole(panel) === 'chart' && !firstChartDone) return { ...s, gw: 12 }
290    return s
291  }
292  const closeRow = () => {
293    if (!row.length) return
294    let spare = cols - used
295    for (let i = 0; spare > 0; i = (i + 1) % row.length, spare--) row[i].gw += 1
296    const flowAlone = row.length === 1 && Boolean(spanOf(row[0].panel).flow)
297    const gh = flowAlone ? null : Math.max(...row.map(c => c.gh ?? 0)) || null
298    for (const c of row) c.gh = gh
299    cells.push(...row); row = []; used = 0
300  }
301  for (const panel of ordered) {
302    const span = spanOf(panel)
303    if (panelRole(panel) === 'chart') firstChartDone = true
304    let gw = clamp(Math.round(span.gw * scale), 1, cols)
305    let minGw = clamp(Math.round((span.minGw ?? span.gw) * scale), 1, gw)
306    // Two-up board: halves pair, wide things and tables (columns need the width) span.
307    if (cols === 6) { gw = span.flow || span.gw >= 9 || panelRole(panel) === 'table' ? 6 : 3; minGw = gw }
308    if (used + gw > cols) { if (used + minGw <= cols) gw = cols - used; else closeRow() }
309    row.push({ panel, gw, gh: span.gh }); used += gw
310    if (used >= cols) closeRow()
311  }
312  closeRow()
313  return cells
314}
315
316function gridRows(cells, cols = 12) {
317  const rows = []; let row = [], used = 0
318  for (const cell of cells) { row.push(cell); used += cell.gw; if (used >= cols) { rows.push(row); row = []; used = 0 } }
319  if (row.length) rows.push(row)
320  return rows
321}
322const gridColsFor = width => width >= 1000 ? 12 : width >= 620 ? 6 : 1
323const sessionGridColsFor = width => width >= 1000 ? 12 : width >= 660 ? 6 : 1
324
325// Session boards have content bands, not desktop widget coordinates. Keep the
326// older packer above for non-session callers. Both HTML and React use this one.
327function sessionGrid(panels, layout, cols) {
328  const headline = p => ['hero', 'stats', 'kpi'].includes(p.type)
329  const groups = { headline: [], visual: [], table: [], narrative: [], takeaway: [] }
330  for (const p of panels) {
331    const role = panelRole(p)
332    groups[headline(p) ? 'headline' : role === 'kpi' || role === 'chart' ? 'visual' : role].push(p)
333  }
334  groups.headline.sort((a, b) => Number(b.type === 'hero') - Number(a.type === 'hero'))
335  const order = layout === 'comparison' ? ['table', 'headline', 'visual', 'narrative', 'takeaway']
336    : layout === 'executive' ? ['headline', 'visual', 'narrative', 'table', 'takeaway']
337    : ['headline', 'visual', 'table', 'narrative', 'takeaway']
338  const cells = []
339  for (const band of order) {
340    const items = groups[band]
341    for (let i = 0; i < items.length;) {
342      const heroPair = band === 'headline' && items[i].type === 'hero' && ['stats', 'kpi'].includes(items[i + 1]?.type) && cols === 12
343      const pair = cols > 1 && ['visual', 'narrative'].includes(band) && i + 1 < items.length
344        && !(layout === 'executive' && band === 'visual' && i === 0)
345        && !(band === 'narrative' && [items[i], items[i + 1]].some(p => ['timeline', 'approval'].includes(p.type)))
346      const row = items.slice(i, i + (heroPair || pair ? 2 : 1))
347      row.forEach((panel, j) => cells.push({ panel, gw: heroPair ? (j ? 8 : 4) : cols / row.length, gh: null }))
348      i += row.length
349    }
350  }
351  return cells
352}
353
354/* ── Chat panel → chart spec (moved from panel-spec.ts; React re-exports) ── */
355const num = v => Number(String(v).replace(/,/g, ''))
356function dashboardPanelSpec(p) {
357  const base = { title: undefined, ...(p.unit !== undefined ? { unit: p.unit } : {}) }
358  switch (p.type) {
359    case 'bar': case 'vertical-bar': case 'radar':
360      return { ...base, type: p.type === 'radar' ? 'radar' : 'bar', orientation: p.type === 'bar' ? 'horizontal' : 'vertical', x: p.items.map(i => i.label), series: [{ name: p.title, data: p.items.map(i => num(i.value)) }] }
361    case 'line': case 'area': case 'stacked-bar': {
362      const x = p.categories || p.series?.[0]?.data.map(d => d.category) || (p.items ? p.items.map(i => i.label) : []) || []
363      const series = p.series?.map(s => ({ name: s.name, data: x.map(c => s.data.find(d => d.category === c)?.value ?? null) })) || [{ name: p.title, data: p.items ? p.items.map(i => i.value) : [] }]
364      return { ...base, type: p.type, x, series }
365    }
366    case 'pie': case 'donut': case 'funnel': return { ...base, type: p.type, series: p.items.map(i => ({ name: i.label, value: i.value })) }
367    case 'gauge': return { ...base, type: 'gauge', title: p.label, value: p.value, max: p.target }
368    case 'scatter': return { ...base, type: 'scatter', series: [{ data: p.points.map(pt => [pt.x, pt.y]) }] }
369    case 'range': case 'boxplot': case 'before-after': return { ...base, type: p.type, items: p.items }
370    case 'sankey': case 'network': return { ...base, type: p.type, links: p.links.map(l => ({ ...l, value: l.value ?? 1 })) }
371    case 'bubble': return { ...base, type: 'bubble', points: p.items.map(i => ({ name: i.label, value: i.value })) }
372    case 'combo': return { ...base, type: 'combo', x: p.categories, series: [{ ...p.bar, type: 'bar' }, { ...p.line, type: 'line' }] }
373    case 'heatmap': {
374      if (!p.rows.every(r => r.slice(1).every(v => String(v).trim() !== '' && Number.isFinite(Number(v))))) return null
375      return { ...base, type: 'heatmap', x: p.headers.slice(1), y: p.rows.map(r => r[0]), series: p.rows.map(r => ({ name: r[0], data: r.slice(1).map(Number) })) }
376    }
377    default: return null
378  }
379}
380/** A panel already written in the data-chart vocabulary (x/series/items/links/points/value). */
381const isChartPanel = p => Boolean(p) && TYPES.includes(p.type) && ['x', 'series', 'items', 'links', 'points', 'value', 'ref'].some(k => p[k] !== undefined) && !(p.items && p.items[0] && 'label' in p.items[0] && !('low' in p.items[0] || 'before' in p.items[0] || 'values' in p.items[0]))
382/** Chart spec for any panel: pass-through for chart-vocabulary panels, else the chat mapping. */
383function chartSpecFor(p) {
384  if (isChartPanel(p)) { const { title: _t, ...spec } = p; return spec }
385  return dashboardPanelSpec(p)
386}
387
388/* ── Structured dashboard model: validation + server HTML ─────────────────── */
389const BOARD_CARD_TYPES = ['hero', 'stats', 'kpi', 'split', 'segments', 'badges', 'state', 'columns', 'leaderboard', 'timeline', 'feed', 'table', 'alert', 'list', 'summary', 'master-detail', 'datafeeds', 'document', 'approval']
390const BOARD_TEMPLATES = ['dashboard-overview', 'dashboard-analysis', 'dashboard-compact', 'dashboard-executive', 'dashboard-comparison']
391const TONES = ['pos', 'neg', 'warn', 'info', 'neutral', 'accent']
392const TEXT_MAX = 2000
393
394function walkStrings(value, fn, path = '') {
395  if (typeof value === 'string') fn(value, path)
396  else if (Array.isArray(value)) value.forEach((v, i) => walkStrings(v, fn, `${path}[${i}]`))
397  else if (value && typeof value === 'object') for (const [k, v] of Object.entries(value)) walkStrings(v, fn, path ? `${path}.${k}` : k)
398}
399const str = (v, where, max = TEXT_MAX, required = true) => {
400  if (v === undefined || v === null) { if (required) fail(`${where} is required`); return }
401  if (typeof v !== 'string' || (required && !v.trim())) fail(`${where} must be non-empty text`)
402  if (v.length > max) fail(`${where} exceeds ${max} characters`)
403}
404const list = (v, where, min, max) => { if (!Array.isArray(v) || v.length < min || v.length > max) fail(`${where} needs ${min}-${max} entries`); if (v.some(it => !it || typeof it !== 'object' || Array.isArray(it))) fail(`${where} entries must be objects`); return v }
405const number = (v, where) => { if (!finite(v)) fail(`${where} must be a number`) }
406const oneOf = (v, where, allowed, required = false) => { if (v === undefined) { if (required) fail(`${where} is required`); return } if (!allowed.includes(v)) fail(`${where} must be one of ${allowed.join('|')}`) }
407
408function validateCard(p, at) {
409  for (const it of p.items || []) {
410    if (it.spark !== undefined && (!Array.isArray(it.spark) || it.spark.length < 2 || it.spark.length > 100 || !it.spark.every(finite))) fail(`${at}: spark needs 2-100 finite observations`)
411    if (it.progress !== undefined && (!finite(it.progress) || it.progress < 0 || it.progress > 100)) fail(`${at}: progress must be between 0 and 100`)
412  }
413  switch (p.type) {
414    case 'hero': str(p.value, `${at}.value`, 40); str(p.caption, `${at}.caption`, 200, false); str(p.delta, `${at}.delta`, 80, false); oneOf(p.trend, `${at}.trend`, ['up', 'down', 'flat']); break
415    case 'stats': case 'kpi': for (const [i, it] of list(p.items, `${at}.items`, 1, 12).entries()) { str(it.label, `${at}.items[${i}].label`, 80); str(it.value, `${at}.items[${i}].value`, 40); str(it.delta, `${at}.items[${i}].delta`, 80, false); oneOf(it.trend, `${at}.items[${i}].trend`, ['up', 'down', 'flat']) } break
416    case 'split': for (const [i, it] of list(p.items, `${at}.items`, 2, 4).entries()) { str(it.label, `${at}.items[${i}].label`, 80); str(it.value, `${at}.items[${i}].value`, 40); oneOf(it.tone, `${at}.items[${i}].tone`, TONES) } break
417    case 'segments': for (const [i, it] of list(p.items, `${at}.items`, 1, 8).entries()) { str(it.label, `${at}.items[${i}].label`, 80); number(it.value, `${at}.items[${i}].value`); if (it.value < 0) fail(`${at}.items[${i}].value must be non-negative`); oneOf(it.tone, `${at}.items[${i}].tone`, TONES) } break
418    case 'badges': for (const [i, it] of list(p.items, `${at}.items`, 1, 20).entries()) { str(it.label, `${at}.items[${i}].label`, 80); oneOf(it.tone, `${at}.items[${i}].tone`, TONES) } break
419    case 'state': oneOf(p.tone, `${at}.tone`, ['gold', 'coral', 'green'], true); str(p.heading, `${at}.heading`, 120); if (p.tags !== undefined) { if (!Array.isArray(p.tags) || p.tags.length > 6) fail(`${at}.tags needs 0-6 entries`);
419 p.tags.forEach((t, i) => str(t, `${at}.tags[${i}]`, 40)) } for (const [i, it] of list(p.items || [], `${at}.items`, 0, 4).entries()) { str(it.value, `${at}.items[${i}].value`, 40); str(it.caption, `${at}.items[${i}].caption`, 80) } break
420    case 'columns': case 'leaderboard': for (const [i, it] of list(p.items, `${at}.items`, 1, 20).entries()) { str(it.label, `${at}.items[${i}].label`, 120); number(it.value, `${at}.items[${i}].value`); if (p.type === 'columns' && it.value < 0) fail(`${at}: column-card values must be non-negative`); str(it.note, `${at}.items[${i}].note`, 200, false) } break
421    case 'timeline': for (const [i, it] of list(p.events, `${at}.events`, 1, 20).entries()) { str(it.date, `${at}.events[${i}].date`, 40); str(it.title, `${at}.events[${i}].title`, 160); str(it.description, `${at}.events[${i}].description`, 600, false) } break
422    case 'feed': for (const [i, it] of list(p.items, `${at}.items`, 1, 20).entries()) { str(it.title, `${at}.items[${i}].title`, 200); str(it.meta, `${at}.items[${i}].meta`, 200, false); str(it.action, `${at}.items[${i}].action`, 120, false); oneOf(it.tone, `${at}.items[${i}].tone`, TONES) } break
423    case 'table':
424      if (p.ref !== undefined) { str(p.ref, `${at}.ref`, 80); if (p.rows !== undefined || p.headers !== undefined) fail(`${at} is bound to data["${p.ref}"]; omit headers and rows`); break }
425      if (!Array.isArray(p.headers) || p.headers.length < 1 || p.headers.length > 12) fail(`${at}.headers needs 1-12 columns`)
426      p.headers.forEach((h, i) => str(h, `${at}.headers[${i}]`, 80))
427      if (!Array.isArray(p.rows) || p.rows.length < 1 || p.rows.length > 50) fail(`${at}.rows needs 1-50 rows`)
428      p.rows.forEach((r, i) => { if (!Array.isArray(r) || r.length !== p.headers.length) fail(`${at}.rows[${i}] must have ${p.headers.length} cells`); r.forEach((c, j) => {
429        if (typeof c === 'string' || finite(c)) return
430        const cell = `${at}.rows[${i}][${j}]`
431        if (!c || typeof c !== 'object' || Array.isArray(c)) fail(`${cell} must be text, a number or a formatted cell`)
432        str(c.text, `${cell}.text`, 600)
433        oneOf(c.tone, `${cell}.tone`, TONES)
434        if (c.href !== undefined && (typeof c.href !== 'string' || !/^https?:\/\/[^\s]+$/i.test(c.href))) fail(`${cell}.href requires an HTTP(S) URL`)
435        if (c.spark !== undefined && (!Array.isArray(c.spark) || c.spark.length < 2 || c.spark.length > 100 || !c.spark.every(finite))) fail(`${cell}.spark needs 2-100 observations`)
436      }) })
437      break
438    case 'alert': str(p.message, `${at}.message`); oneOf(p.variant, `${at}.variant`, ['info', 'warning', 'error', 'success']); break
439    case 'list': for (const [i, it] of list(p.items, `${at}.items`, 1, 20).entries()) { str(it.title, `${at}.items[${i}].title`, 200); str(it.description, `${at}.items[${i}].description`, 600, false); str(it.badge, `${at}.items[${i}].badge`, 40, false) } break
440    case 'summary': for (const [i, it] of list(p.items, `${at}.items`, 1, 12).entries()) { str(it.label, `${at}.items[${i}].label`, 80); str(it.value, `${at}.items[${i}].value`, 80); str(it.description, `${at}.items[${i}].description`, 400, false) } break
441    case 'datafeeds': for (const it of list(p.items, `${at}.items`, 1, 20)) { str(it.label, `${at}.label`, 120); str(it.value, `${at}.value`, 80) } break
442    case 'document': for (const it of list(p.items, `${at}.items`, 1, 20)) { str(it.name, `${at}.name`, 160); str(it.meta, `${at}.meta`, 200, false); str(it.badge, `${at}.badge`, 40, false); oneOf(it.tone, `${at}.tone`, TONES) } break
443    case 'master-detail': for (const it of list(p.items, `${at}.items`, 1, 20)) { str(it.name, `${at}.name`, 160); str(it.subtitle, `${at}.subtitle`, 200, false); for (const field of list(it.fields, `${at}.fields`, 1, 20)) { str(field.key, `${at}.key`, 80); str(field.value, `${at}.value`, 600) } } break
444    case 'approval': str(p.heading, `${at}.heading`, 160); str(p.subtitle, `${at}.subtitle`, 200, false); str(p.draft, `${at}.draft`, TEXT_MAX, false); for (const it of list(p.meta, `${at}.meta`, 0, 12)) { str(it.label, `${at}.label`, 80); str(it.value, `${at}.value`, 200) } break
445    default: fail(`${at}
445.type "${p.type}" is not supported; use one of ${BOARD_CARD_TYPES.join('|')} or a chart type ${TYPES.join('|')}`)
446  }
447}
448
449function validateBoard(model) {
450  if (!model || typeof model !== 'object' || Array.isArray(model)) fail('Dashboard model must be an object')
451  if (model.version !== 1) fail('version must be 1')
452  if (model.family !== 'dashboard') fail('family must be "dashboard"')
453  oneOf(model.templateId, 'templateId', BOARD_TEMPLATES, true)
454  str(model.title, 'title', 120); str(model.subtitle, 'subtitle', 160, false)
455  oneOf(model.canvas, 'canvas', ['light', 'dark']); oneOf(model.theme, 'theme', Object.keys(THEMES))
456  if (JSON.stringify(model).length > 400_000) fail('Dashboard model exceeds 400 KB')
457  const panels = list(model.panels, 'panels', 2, 12)
458  panels.forEach((p, i) => {
459    const at = `panels[${i}]`
460    if (typeof p.type !== 'string') fail(`${at}.type is required`)
461    str(p.title, `${at}.title`, 160, false)
462    walkStrings(p, (s, path) => { if (s.length > TEXT_MAX) fail(`${at}.${path} exceeds ${TEXT_MAX} characters`) })
463    if (TYPES.includes(p.type)) {
464      if (JSON.stringify(p).includes('{{')) fail(`${at} (${p.type}): charts take bound data through "ref", never {{…}} placeholders`)
465      if (p.ref !== undefined) {
466        str(p.ref, `${at}.ref`, 80)
467        if (['x', 'y', 'series', 'items', 'value', 'points', 'links', 'nodes'].some(k => p[k] !== undefined)) fail(`${at}: bound charts must not override their dataset`)
468        return
469      }
470      try { validateDashboardChart(chartSpecFor(p)) } catch (error) { fail(`${at} (${p.type}): ${error.message}`) }
471      return
472    }
473    validateCard(p, `${at} (${p.type})`)
474  })
475  if (panels.filter(p => p.type === 'hero').length > 1) fail('At most one hero panel')
476  return model
477}
478
479const TONE_VAR = { pos: '--pos', neg: '--neg', warn: '--warn', info: '--info', neutral: '--neutral', accent: '--accent' }
480const BADGE_CLASS = { pos: 'pos', neg: 'neg', warn: 'warn', info: 'info', neutral: 'neu', accent: 'accent' }
481const ALERT_TONE = { info: 'info', warning: 'warn', error: 'neg', success: 'pos' }
482const tri = trend => trend === 'down' ? '▼' : trend === 'up' ? '▲' : ''
483const chip = (trend, delta) => delta ? `<span class="chip-trend ${trend === 'down' ? 'down' : trend === 'up' ? 'up' : ''}">${tri(trend)} ${esc(delta)}</span>` : ''
484const ptitle = title => title ? `<h2 class="ptitle">${esc(title)}</h2>` : ''
485const numeric = v => typeof v === 'number' || /^[-+]?[$€£¥₩]?\s?[\d,.]+\s?[%kKmMbBtT]?$/.test(String(v).trim())
486const chartAttr = spec => JSON.stringify(spec).replace(/'/g, '&#39;')
487
488function sparkHtml(values) {
489  if (!Array.isArray(values) || values.length < 2 || !values.every(finite)) return ''
490  const low = Math.min(...values), span = Math.max(...values) - low || 1
491  const points = values.map((v, i) => `${i * 240 / (values.length - 1)},${44 - (v - low) / span * 40}`).join(' ')
492  return `<svg class="sd-spark" viewBox="0 0 240 48" role="img" aria-label="Trend: ${esc(values.join(', '))}"><polyline points="${points}" fill="none" stroke="var(--info)" stroke-width="2" vector-effect="non-scaling-stroke"/></svg>`
493}
494
495function statHtml(it) {
496  const value = `<div class="v num" data-fit-value style="--sd-value-width:${statValueWidth(it.value)}">${esc(it.value)}${it.unit ? `<small class="muted"> ${esc(it.unit)}</small>` : ''}</div><div class="l">${esc(it.label)}</div>`
497  if (finite(it.progress)) return `<div class="ringkpi"><svg viewBox="0 0 64 64" class="sd-ring" role="img" aria-label="${esc(it.label)}: ${it.progress}%"><circle cx="32" cy="32" r="26" fill="none" stroke="var(--surface-3)" stroke-width="6"/><circle cx="32" cy="32" r="26" fill="none" stroke="var(--info)" stroke-width="6" pathLength="100" stroke-dasharray="${Math.max(0, Math.min(100, it.progress))} 100" transform="rotate(-90 32 32)"/></svg><div class="meta">${value}${chip(it.trend, it.delta)}</div></div>`
498  return it.spark ? `<div class="spark"><div class="top"><div>${value}</div>${chip(it.trend, it.delta)}</div>${sparkHtml(it.spark)}</div>` : `<div class="kpi">${value}${chip(it.trend, it.delta)}</div>`
499}
500
501// Conservative text width in ems; cqi handles pane resizing without observers.
502// The iframe recalculates this after server data bindings resolve.
503function statValueWidth(value) {
504  return Math.max(1, [...String(value ?? '')].reduce((sum, c) => sum + (/[.,\s]/.test(c) ? .35 : /[^\x00-\x7f]|[MW]/.test(c) ? 1.05 : .68), 0)).toFixed(2)
505}
506
507function tableHtml(p) {
508  if (p.ref) return `${ptitle(p.title)}<div class="table-scroll">{{table:${esc(p.ref)}}}</div>`
509  const right = p.headers.map((_, j) => p.rows.length > 0 && p.rows.every(r => r[j] === '' || numeric(r[j]?.text ?? r[j])))
510  const head = p.headers.map((h, j) =>
510 `<th${right[j] ? ' class="r"' : ''}>${esc(h)}</th>`).join('')
511  const cell = c => {
512    if (c && typeof c === 'object') {
513      const text = c.tone ? `<span class="badge ${BADGE_CLASS[c.tone] || 'neu'}">${esc(c.text)}</span>` : esc(c.text)
514      return `${/^https?:\/\/[^\s]+$/i.test(c.href || '') ? `<a class="link" href="${esc(c.href)}" target="_blank" rel="noopener noreferrer">${text}</a>` : text}${c.spark ? sparkHtml(c.spark) : ''}`
515    }
516    return /^https?:\/\/[^\s]+$/i.test(String(c)) ? `<a class="link" href="${esc(c)}" target="_blank" rel="noopener noreferrer">${esc(c)}</a>` : esc(c)
517  }
518  const body = p.rows.map(r => `<tr>${r.map((c, j) => `<td${right[j] ? ' class="r num"' : ''}>${cell(c)}</td>`).join('')}</tr>`).join('')
519  return `${ptitle(p.title)}<div class="table-scroll"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
520}
521
522function cardHtml(p) {
523  switch (p.type) {
524    case 'hero': return `<div class="hero"><div class="cap">${esc(p.caption || p.title || '')}</div><div class="v num">${esc(p.value)}</div>${p.delta ? `<div style="margin-top:8px;font-size:12px;opacity:.9">${tri(p.trend)} ${esc(p.delta)}</div>` : ''}</div>`
525    // Tiles ARE cards (reference `panel > kpi`): no outer panel, the cell is bare.
526    case 'stats': case 'kpi': return `${ptitle(p.title)}<div class="sd-kpis">${p.items.map(it => `<div class="panel" style="container-type:inline-size">${statHtml(it)}</div>`).join('')}</div>`
527    case 'split': return `${ptitle(p.title)}<div class="cmp">${p.items.map(it => `<div class="half"><div class="v num"${it.tone ? ` style="color:var(${TONE_VAR[it.tone]})"` : ''}>${esc(it.value)}</div><div class="l">${esc(it.label)}</div></div>`).join('')}</div>`
528    case 'segments': {
529      const total = p.items.reduce((s, it) => s + it.value, 0) || 1
530      return `${ptitle(p.title)}<div class="segbar">${p.items.map((it, i) => `<i style="width:${(it.value / total * 100).toFixed(2)}%;background:var(${TONE_VAR[it.tone] || `--cat-${['indigo', 'green', 'coral', 'sky', 'orange', 'magenta', 'gold', 'acid'][i % 8]}`})"></i>`).join('')}</div><div class="legend">${p.items.map((it, i) => `<span><i class="dot" style="background:var(${TONE_VAR[it.tone] || `--cat-${['indigo', 'green', 'coral', 'sky', 'orange', 'magenta', 'gold', 'acid'][i % 8]}`})"></i>${esc(it.label)} <b class="num">${esc(it.value)}</b></span>`).join('')}</div>`
531    }
532    case 'badges': return `${ptitle(p.title)}<div style="display:flex;flex-wrap:wrap;gap:8px">${p.items.map(it => `<span class="badge ${BADGE_CLASS[it.tone] || 'neu'}">${esc(it.label)}</span>`).join('')}</div>`
533    case 'state': return `<div class="state ${esc(p.tone)}"><h4>${esc(p.heading)}</h4>${p.tags?.length ? `<div class="tags">${p.tags.map(t => `<span>${esc(t)}</span>`).join('')}</div>` : ''}${p.items?.length ? `<div class="nums">${p.items.map(it => `<div><div class="n num">${esc(it.value)}</div><div class="c">${esc(it.caption)}</div></div>`).join('')}</div>` : ''}</div>`
534    case 'columns': {
535      const max = Math.max(1, ...p.items.map(it => it.value))
536      return `${ptitle(p.title)}<div class="sd-columns">${p.items.map(it => `<div class="sd-column"><span class="num">${esc(it.value)}</span><span>${esc(it.label)}</span><i style="height:${Math.max(0, it.value) / max * 100}%"></i></div>`).join('')}</div>`
537    }
538    case 'master-detail': return `${ptitle(p.title)}<div class="sd-details">${p.items.map((it, i) => `<details${i === 0 ? ' open' : ''}><summary><b>${esc(it.name)}</b>${it.subtitle ? `<span class="muted"> ${esc(it.subtitle)}</span>` : ''}</summary><dl>${it.fields.map(f => `<div><dt>${esc(f.key)}</dt><dd>${esc(f.value)}</dd></div>`).join('')}</dl></details>`).join('')}</div>`
539    case 'document': return `${ptitle(p.title)}<div class="feeds">${p.items.map(it => `<div class="row"><span class="fi" aria-hidden="true">▤</span><span class="fn"><b>${esc(it.name)}</b>${it.meta ? `<br><small class="muted">${esc(it.meta)}</small>` : ''}</span>${it.badge ? `<span class="badge ${BADGE_CLASS[it.tone] || 'neu'}">${esc(it.badge)}</span>` : ''}</div>`).join('')}</div>`
540    case 'datafeeds': return cardHtml({ ...p, type: 'summary' })
541    case 'approval': return `${ptitle(p.title)}<div class="sd-approval"><h3>${esc(p.heading)}</h3>${p.subtitle ? `<p class="muted">${esc(p.subtitle)}</p>` : ''}${p.draft ? `<blockquote>${esc(p.draft)}</blockquote>` : ''}${cardHtml({ type: 'summary', items: p.meta })}</div>`
542    case 'leaderboard': {
543      const max = Math.max(1, ...p.items.map(it => Math.abs(it.value)))
544      return `${ptitle(p.title)}<table class="lead"><tbody>${p.items.map((it, i) => `<tr><td style="width:30px"><span class="rank">${i + 1}</span></td><td>${esc(it.label)}${it.note ? `<br><small class="muted">${esc(it.note)}</small>` : ''}</td><td style="width:34%"><div class="bar"><i style="width:${(Math.abs(it.value) / max * 100).toFixed(2)}%"></i></div></td><td class="r mono" style="white-space:nowrap;padding-left:12px"><b>${esc(it.value)}</b></td></tr>`).join('')}</tbody></table>`
545    }
546    case 'timeline': return `${ptitle(p.title)}<div class="feed">${p.events.map(e => `<div class="alert"><div class="ameta"><span class="mono">${esc(e.date)}</span></div><div class="atitle">${esc(e.title)}</div>${e.description ? `<p class="muted" style="font-size:13px;margin-top:4px">${esc(e.description)}</p>` : ''}</div>`).join('')}</div>`
547    case 'feed': return `${ptitle(p.title)}<div class="feed">${p.items.map(it => `<div class="alert"><div class="atitle"><i class="dot ${BADGE_CLASS[it.tone] || 'neu'}"></i>${esc(it.title)}</div>${it.meta || it.action ? `<div class="ameta">${it.meta ? `<span>${esc(it.meta)}</span>` : ''}${it.meta && it.action ? '<span class="sep"></span>' : ''}${it.action ? `<span class="badge accent">${esc(it.action)}</span>` : ''}</div>` : ''}</div>`).join('')}</div>`
548    case 'table': return tableHtml(p)
549    case 'alert': return `${ptitle(p.title)}<div style="display:flex;gap:12px;align-items:flex-start"><span class="badge ${ALERT_TONE[p.variant] || 'info'}" style="flex:none;margin-top:2px">${esc(p.variant || 'info')}</span>
549<p style="font-size:14px">${esc(p.message)}</p></div>`
550    case 'list': return `${ptitle(p.title)}<div class="feeds">${p.items.map(it => `<div class="row"><span class="fn"><b>${esc(it.title)}</b>${it.description ? ` <span class="muted">${esc(it.description)}</span>` : ''}</span>${it.badge ? `<span class="badge neu">${esc(it.badge)}</span>` : ''}</div>`).join('')}</div>`
551    case 'summary': return `${ptitle(p.title)}<div class="feeds">${p.items.map(it => `<div class="row"><span class="fn">${esc(it.label)}${it.description ? `<br><small class="muted">${esc(it.description)}</small>` : ''}</span><span class="fv">${esc(it.value)}</span></div>`).join('')}</div>`
552    default: return ''
553  }
554}
555
556const BARE = new Set(['hero', 'state', 'stats', 'kpi'])
557const isBoardCard = p => Boolean(p) && BOARD_CARD_TYPES.includes(p.type)
558/** Inner catalogue markup for one card panel + whether it carries its own frame (no .panel chrome).
559 *  The React Data pane renders chat panels through this so pane, preview and PDF share one widget kit. */
560function panelCardHtml(p) {
561  if (!isBoardCard(p)) return null
562  const html = cardHtml(p)
563  return html ? { html, bare: BARE.has(p.type) } : null
564}
565/** Server-side board: the same packer as the React Data pane, catalogue markup, charts drawn by the runtime. */
566function renderBoardHtml(model) {
567  const m = validateBoard(model)
568  const layout = layoutForTemplate(m.templateId)
569  const cells = layoutGrid(m.panels, layout, 12, true), six = layoutGrid(m.panels, layout, 6, true)
570  const gw6 = new Map(six.map(c => [c.panel, c.gw]))
571  const body = cells.map(c => {
572    const p = c.panel, isChart = TYPES.includes(p.type)
573    const inner = isChart
574      ? `${ptitle(p.title)}${p.unit ? `<p class="sd-chart-unit">${esc(p.unit)}</p>` : ''}<div data-chart='${chartAttr(chartSpecFor(p))}' style="height:300px"></div>`
575      : cardHtml(p)
576    const cls = BARE.has(p.type) ? 'board-bare' : 'panel'
577    // Content-driven heights: the chart div is a fixed 300px, neighbours stretch to the row (CSS grid).
578    return `<section class="${cls}" data-gw="${c.gw}" data-gw6="${gw6.get(p) ?? Math.min(6, Math.ceil(c.gw / 2))}">${inner}</section>`
579  }).join('\n')
580  return `<!doctype html>
581<html lang="en">
582<head>
583<meta charset="utf-8">
584<meta name="viewport" content="width=device-width,initial-scale=1">
585<title>${esc(m.title)}</title>
586<meta name="palette-dashboard" content="${esc(m.templateId)}">
587</head>
588<body class="session-dashboard" data-session-dashboard="nocturne-v1" data-canvas="${esc(m.canvas || 'light')}" data-theme="${esc(m.theme || 'signal')}">
589<main>
590<header class="dhead">${m.subtitle ? `<div class="eyebrow">${esc(m.subtitle)}</div>` : ''}<h1>${esc(m.title)}</h1></header>
591<div class="board">
592${body}
593</div>
594</main>
595</body>
596</html>
597`
598}
599
600function start(){
601/* Included after core.mjs in the generated dashboard runtime. */
602const roots = document.querySelectorAll('[data-session-dashboard="nocturne-v1"]');
603for (const root of roots) {
604  root.querySelectorAll('[data-fit-value]').forEach(node => node.style.setProperty('--sd-value-width', statValueWidth(node.textContent)));
605  const charts = new Map();
606  const theme = () => dashboardTheme(root.getAttribute('data-canvas'), root.getAttribute('data-theme'));
607  function render(node) {
608    let chart;
609    try {
610      const spec = JSON.parse(node.getAttribute('data-chart'));
611      validateDashboardChart(spec);
612      if (spec.type === 'geo') {
613        if (!window.__SESSION_DASHBOARD_WORLD__) throw new Error('Map asset unavailable');
614        echarts.registerMap('session-dashboard-world', window.__SESSION_DASHBOARD_WORLD__);
615      }
616      if (typeof echarts === 'undefined') throw new Error('Chart runtime unavailable');
617      node.style.height ||= spec.type === 'sparkline' ? '44px' : spec.type === 'ring' ? '90px' : '300px';
618      node.setAttribute('role', 'img');
619      node.setAttribute('aria-label', spec.title || spec.type + ' chart');
620      node.textContent = '';
621      // A panel that already carries its own title (.ptitle/h2) must not repeat it inside the chart.
622      const titled = node.parentElement && node.parentElement.querySelector(':scope > :is(.ptitle,h2,h3,.dbPanelHead)');
623      const drawn = titled && spec.title ? { ...spec, title: undefined } : spec;
624      chart = echarts.init(node, null, { renderer: 'svg' });
625      chart.setOption(buildDashboardOption(drawn, theme()));
626      node.setAttribute('data-chart-rendered', '1');
627      node.removeAttribute('data-chart-error');
628      const details = document.createElement('details');
629      details.className = 'sd-chart-data';
630      const summary = document.createElement('summary');
631      summary.textContent = 'View chart data';
632      details.appendChild(summary);
633      const table = document.createElement('table');
634      const caption = document.createElement('caption');
635      caption.textContent = spec.title || spec.type + ' data'; table.appendChild(caption);
636      for (const row of chartRows(spec)) {
637        const tr = document.createElement('tr');
638        row.forEach((value, i) => { const cell = document.createElement(i ? 'td' : 'th'); if (!i) cell.setAttribute('scope', 'row'); cell.textContent = value == null ? '—' : String(value); tr.appendChild(cell); });
639        table.appendChild(tr);
640      }
641      details.appendChild(table); (node.closest('.ringkpi') || node).insertAdjacentElement('afterend', details);
642      const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => chart.resize()) : null;
643      observer?.observe(node);
644      charts.set(node, { chart, spec: drawn, observer, details });
645    } catch (error) {
646      chart?.dispose();
647      node.setAttribute('data-chart-error', '1');
648      node.setAttribute('data-chart-rendered', 'error');
649      node.setAttribute('role', 'status');
650      node.textContent = 'Chart unavailable: ' + error.message;
651    }
652  }
653  root.querySelectorAll('[data-chart]').forEach(render);
654  const updateControls = () => {
655    for (const attr of ['canvas', 'theme']) root.querySelectorAll(`[data-${attr}-btn]`).forEach(button => {
656      button.setAttribute('aria-pressed', String(button.getAttribute(`data-${attr}-btn`) === root.getAttribute(`data-${attr}`)));
657    });
658  };
659  updateControls();
660  const observer = new MutationObserver(records => {
661    if (records.some(r => r.target === root)) {
662      for (const { chart, spec } of charts.values()) chart.setOption(buildDashboardOption(spec, theme()), true);
663      updateControls();
664    }
665    for (const record of records) if (record.attributeName === 'data-chart') {
666      const old = charts.get(record.target);
667      old?.observer?.disconnect();
667 old?.chart.dispose(); old?.details.remove(); charts.delete(record.target);
668      render(record.target);
669    }
670  });
671  observer.observe(root, { attributes: true, subtree: true, attributeFilter: ['data-canvas', 'data-theme', 'data-chart'] });
672  // Native buttons/selects work with keyboard and touch; filter whole rows or
673  // panels, preserving their data and rescaling charts after they become visible.
674  const filter = control => {
675    const group = control.getAttribute('data-filter');
676    if (!group) return;
677    const value = control.value || control.getAttribute('data-value') || 'all';
678    root.querySelectorAll('[data-filter-group]').forEach(item => {
679      if (item.getAttribute('data-filter-group') === group) item.hidden = value !== 'all' && item.getAttribute('data-filter-value') !== value;
680    });
681    root.querySelectorAll('button[data-filter]').forEach(button => {
682      if (button.getAttribute('data-filter') === group) button.setAttribute('aria-pressed', String((button.value || button.getAttribute('data-value')) === value));
683    });
684    for (const { chart } of charts.values()) chart.resize();
685  };
686  root.querySelectorAll('select[data-filter],button[data-filter][aria-pressed="true"],button[data-filter][value="all"]').forEach(filter);
687  root.addEventListener('click', event => {
688    const button = event.target.closest('button');
689    if (!button) return;
690    if (button.hasAttribute('data-canvas-btn')) root.setAttribute('data-canvas', button.getAttribute('data-canvas-btn'));
691    if (button.hasAttribute('data-theme-btn')) root.setAttribute('data-theme', button.getAttribute('data-theme-btn'));
692    filter(button);
693  });
694  root.addEventListener('change', event => { if (event.target.matches('select[data-filter]')) filter(event.target); });
695  window.addEventListener('pagehide', () => { observer.disconnect(); for (const { chart, observer: resize } of charts.values()) { resize?.disconnect(); chart.dispose(); } }, { once: true });
696}
697
698}
699if(document.readyState === "loading") document.addEventListener("DOMContentLoaded",start); else start();
700})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.