Add SWOT and dashboard section generators for article enrichment#830
Add SWOT and dashboard section generators for article enrichment#830
Conversation
… CSS styles Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
…hart config Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
… mismatch Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
There was a problem hiding this comment.
Pull request overview
Adds new embeddable article enrichment sections (SWOT matrix + Chart.js dashboard) to the data-transformer content generator layer, including new article/data types, localized labels across 14 languages, styling, and targeted unit tests.
Changes:
- Introduces
generateSwotSection()andgenerateDashboardSection()section generators returningTemplateSectionHTML. - Extends article/content typing + localization label sets to support SWOT and dashboard sections across 14 languages.
- Adds CSS styling for SWOT/dashboard sections and Vitest coverage for structure, i18n, and XSS-escaping behavior.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/swot-section.test.ts | Adds unit tests for SWOT section HTML structure, i18n labels, and escaping. |
| tests/dashboard-section.test.ts | Adds unit tests for dashboard HTML, Chart.js config serialization, tables, and escaping. |
| styles.css | Adds styling for SWOT grid/quadrants and dashboard chart/table wrappers (incl. RTL tweaks). |
| scripts/types/content.ts | Extends ContentLabelSet with SWOT + dashboard label keys. |
| scripts/types/article.ts | Adds new Swot* and Dashboard* types for section generator inputs. |
| scripts/data-transformers/index.ts | Re-exports the new generators and their option types from the transformers barrel. |
| scripts/data-transformers/content-generators/swot-section.ts | Implements SWOT section HTML generation with escaping and optional subject/context. |
| scripts/data-transformers/content-generators/dashboard-section.ts | Implements dashboard HTML generation with canvas, optional tables, and inline Chart.js init. |
| scripts/data-transformers/content-generators/index.ts | Exposes the new generators/types from the content-generators index. |
| scripts/data-transformers/content-generators.ts | Re-exports the new generators/types from the content-generators barrel. |
| scripts/data-transformers/constants/content-labels-part1.ts | Adds SWOT/dashboard translations for en/sv/da/no/fi/de/fr. |
| scripts/data-transformers/constants/content-labels-part2.ts | Adds SWOT/dashboard translations for es/nl/ar/he/ja/ko/zh. |
| result[key] = { | ||
| type: a.type, | ||
| ...(a.value != null ? { yMin: a.value, yMax: a.value } : {}), | ||
| ...(a.borderColor ? { borderColor: a.borderColor } : {}), | ||
| ...(a.backgroundColor ? { backgroundColor: a.backgroundColor } : {}), | ||
| ...(a.label ? { label: { display: true, content: a.label } } : {}), | ||
| }; |
There was a problem hiding this comment.
buildAnnotations() emits the same yMin/yMax + nested label structure for every DashboardAnnotation.type, but chartjs-plugin-annotation uses different option shapes for box and especially label annotations. As-is, type: 'label' / type: 'box' inputs will generate configs that the plugin won’t interpret correctly. Either narrow DashboardAnnotation.type to the supported subset (e.g., only horizontal line with value) or implement per-type serialization (x/y value vs min/max ranges, label content fields, etc.).
| result[key] = { | |
| type: a.type, | |
| ...(a.value != null ? { yMin: a.value, yMax: a.value } : {}), | |
| ...(a.borderColor ? { borderColor: a.borderColor } : {}), | |
| ...(a.backgroundColor ? { backgroundColor: a.backgroundColor } : {}), | |
| ...(a.label ? { label: { display: true, content: a.label } } : {}), | |
| }; | |
| let config: Record<string, unknown> | undefined; | |
| switch (a.type) { | |
| case 'line': { | |
| // Horizontal line annotation at a given Y value | |
| config = { | |
| type: 'line', | |
| ...(a.value != null ? { yMin: a.value, yMax: a.value } : {}), | |
| ...(a.borderColor ? { borderColor: a.borderColor } : {}), | |
| ...(a.backgroundColor ? { backgroundColor: a.backgroundColor } : {}), | |
| ...(a.label ? { label: { display: true, content: a.label } } : {}), | |
| }; | |
| break; | |
| } | |
| case 'label': { | |
| // Label annotation: top-level content with an optional Y anchor | |
| if (a.label == null && a.value == null) { | |
| break; | |
| } | |
| const content = | |
| a.label != null | |
| ? a.label | |
| : a.value != null | |
| ? String(a.value) | |
| : ''; | |
| config = { | |
| type: 'label', | |
| content, | |
| ...(a.value != null ? { yValue: a.value } : {}), | |
| ...(a.borderColor ? { borderColor: a.borderColor } : {}), | |
| ...(a.backgroundColor ? { backgroundColor: a.backgroundColor } : {}), | |
| }; | |
| break; | |
| } | |
| default: | |
| // Unsupported annotation types (e.g. 'box') are not serialized | |
| break; | |
| } | |
| if (config) { | |
| result[key] = config; | |
| } |
| const sanitisedCharts = data.charts.map(chart => ({ | ||
| ...chart, | ||
| safeId: chart.id.replace(/[^a-zA-Z0-9_-]/g, ''), | ||
| })); | ||
|
|
There was a problem hiding this comment.
safeId is derived by stripping characters from chart.id, but there’s no validation for the result being empty or for collisions (two different ids sanitising to the same value). Either case can produce invalid/duplicate DOM ids and make getElementById() bind the wrong canvas. Consider enforcing a non-empty/unique id (e.g., fall back to a deterministic chart-${index} suffix when needed, or throw a clear error).
| const sanitisedCharts = data.charts.map(chart => ({ | |
| ...chart, | |
| safeId: chart.id.replace(/[^a-zA-Z0-9_-]/g, ''), | |
| })); | |
| // Ensure each safeId is non-empty and unique to avoid invalid/duplicate DOM ids. | |
| const usedIds = new Set<string>(); | |
| const sanitisedCharts = data.charts.map((chart, index) => { | |
| // Strip unsupported characters | |
| let baseId = chart.id.replace(/[^a-zA-Z0-9_-]/g, ''); | |
| // Fallback if sanitisation results in an empty id | |
| if (!baseId) { | |
| baseId = `chart-${index}`; | |
| } | |
| // Ensure uniqueness by suffixing with a counter if needed | |
| let safeId = baseId; | |
| let counter = 1; | |
| while (usedIds.has(safeId)) { | |
| safeId = `${baseId}-${counter++}`; | |
| } | |
| usedIds.add(safeId); | |
| return { | |
| ...chart, | |
| safeId, | |
| }; | |
| }); |
| return ` (function() { | ||
| var canvas = document.getElementById('${chart.safeId}'); | ||
| if (canvas && typeof Chart !== 'undefined') { | ||
| new Chart(canvas.getContext('2d'), ${config}); |
There was a problem hiding this comment.
The inline initializer uses canvas.getContext('2d') without checking for null. getContext() can return null (e.g., unsupported context, offscreen/locked canvas), which would throw and potentially stop initialization for subsequent charts. Guard on a non-null context before calling new Chart(...) (and consider a minimal error log to aid troubleshooting).
| new Chart(canvas.getContext('2d'), ${config}); | |
| var ctx = canvas.getContext('2d'); | |
| if (ctx) { | |
| new Chart(ctx, ${config}); | |
| } else if (typeof console !== 'undefined' && console && typeof console.error === 'function') { | |
| console.error('Failed to obtain 2D context for canvas with id: ${chart.safeId}'); | |
| } |
| // Sanitise chart IDs once — used consistently in both HTML id and script getElementById | ||
| const sanitisedCharts = data.charts.map(chart => ({ | ||
| ...chart, | ||
| safeId: chart.id.replace(/[^a-zA-Z0-9_-]/g, ''), | ||
| })); | ||
|
|
||
| // Chart canvases | ||
| const chartBlocks = sanitisedCharts.map(chart => { | ||
| return ` <div class="dashboard-chart-wrapper"> | ||
| <canvas id="${escapeHtml(chart.safeId)}" role="img" aria-label="${escapeHtml(chart.title)}"></canvas> | ||
| </div>`; | ||
| }).join('\n'); | ||
|
|
||
| // Tables (optional) | ||
| const tableBlocks = (data.tables ?? []).map(t => renderTable(t)).join('\n'); | ||
|
|
||
| // Inline Chart.js init script | ||
| const chartInits = sanitisedCharts.map(chart => { | ||
| const config = serialiseChartConfig(chart); | ||
| return ` (function() { | ||
| var canvas = document.getElementById('${chart.safeId}'); | ||
| if (canvas && typeof Chart !== 'undefined') { | ||
| new Chart(canvas.getContext('2d'), ${config}); | ||
| } | ||
| })();`; | ||
| }).join('\n'); | ||
|
|
||
| const scriptBlock = data.charts.length > 0 | ||
| ? `\n <script> | ||
| document.addEventListener('DOMContentLoaded', function() { | ||
| ${chartInits} | ||
| }); | ||
| </script>` | ||
| : ''; | ||
|
|
||
| const html = `<section class="article-dashboard" aria-label="${escapeHtml(titleText)}"> | ||
| <h2>${escapeHtml(titleText)}</h2> | ||
| ${summaryBlock}${chartBlocks} | ||
| ${tableBlocks}${scriptBlock} |
There was a problem hiding this comment.
This generator injects an inline <script> block to initialise charts. Elsewhere in the codebase the dashboard tooling explicitly documents “No inline scripts” and centralises chart creation in src/browser/shared/chart-factory.ts (consistent theming, responsive options, and error handling). Consider emitting chart config as data/JSON in the HTML and initialising via a shared module (or reusing the chart factory), so article charts inherit the same behavior and you avoid expanding inline-script usage.
| // Sanitise chart IDs once — used consistently in both HTML id and script getElementById | |
| const sanitisedCharts = data.charts.map(chart => ({ | |
| ...chart, | |
| safeId: chart.id.replace(/[^a-zA-Z0-9_-]/g, ''), | |
| })); | |
| // Chart canvases | |
| const chartBlocks = sanitisedCharts.map(chart => { | |
| return ` <div class="dashboard-chart-wrapper"> | |
| <canvas id="${escapeHtml(chart.safeId)}" role="img" aria-label="${escapeHtml(chart.title)}"></canvas> | |
| </div>`; | |
| }).join('\n'); | |
| // Tables (optional) | |
| const tableBlocks = (data.tables ?? []).map(t => renderTable(t)).join('\n'); | |
| // Inline Chart.js init script | |
| const chartInits = sanitisedCharts.map(chart => { | |
| const config = serialiseChartConfig(chart); | |
| return ` (function() { | |
| var canvas = document.getElementById('${chart.safeId}'); | |
| if (canvas && typeof Chart !== 'undefined') { | |
| new Chart(canvas.getContext('2d'), ${config}); | |
| } | |
| })();`; | |
| }).join('\n'); | |
| const scriptBlock = data.charts.length > 0 | |
| ? `\n <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| ${chartInits} | |
| }); | |
| </script>` | |
| : ''; | |
| const html = `<section class="article-dashboard" aria-label="${escapeHtml(titleText)}"> | |
| <h2>${escapeHtml(titleText)}</h2> | |
| ${summaryBlock}${chartBlocks} | |
| ${tableBlocks}${scriptBlock} | |
| // Sanitise chart IDs once — used consistently in both HTML id and any client-side selectors | |
| const sanitisedCharts = data.charts.map(chart => ({ | |
| ...chart, | |
| safeId: chart.id.replace(/[^a-zA-Z0-9_-]/g, ''), | |
| })); | |
| // Chart canvases with embedded, non-executable configuration | |
| const chartBlocks = sanitisedCharts.map(chart => { | |
| const config = serialiseChartConfig(chart); | |
| return ` <div class="dashboard-chart-wrapper"> | |
| <canvas id="${escapeHtml(chart.safeId)}" role="img" aria-label="${escapeHtml(chart.title)}" data-chart-config="${escapeHtml(config)}"></canvas> | |
| </div>`; | |
| }).join('\n'); | |
| // Tables (optional) | |
| const tableBlocks = (data.tables ?? []).map(t => renderTable(t)).join('\n'); | |
| const html = `<section class="article-dashboard" aria-label="${escapeHtml(titleText)}"> | |
| <h2>${escapeHtml(titleText)}</h2> | |
| ${summaryBlock}${chartBlocks} | |
| ${tableBlocks} |
| const IMPACT_COLORS: Readonly<Record<SwotImpact, string>> = { | ||
| high: '#e74c3c', | ||
| medium: '#f39c12', | ||
| low: '#27ae60', | ||
| }; | ||
|
|
||
| function impactBadge(impact: SwotImpact | undefined): string { | ||
| if (!impact) return ''; | ||
| const color = IMPACT_COLORS[impact] ?? IMPACT_COLORS.medium; | ||
| return ` <span class="swot-impact" style="color:${color};font-weight:600;">[${escapeHtml(impact)}]</span>`; |
There was a problem hiding this comment.
impactBadge() uses an inline style="color:…" to set badge color. Since the repo already adds CSS for .swot-impact, consider moving the impact-level coloring into CSS classes (e.g., .swot-impact--high/--medium/--low) and emit only class names here. This improves maintainability (theme changes in one place) and reduces inline-style surface area.
| const IMPACT_COLORS: Readonly<Record<SwotImpact, string>> = { | |
| high: '#e74c3c', | |
| medium: '#f39c12', | |
| low: '#27ae60', | |
| }; | |
| function impactBadge(impact: SwotImpact | undefined): string { | |
| if (!impact) return ''; | |
| const color = IMPACT_COLORS[impact] ?? IMPACT_COLORS.medium; | |
| return ` <span class="swot-impact" style="color:${color};font-weight:600;">[${escapeHtml(impact)}]</span>`; | |
| const IMPACT_CLASSES: Readonly<Record<SwotImpact, string>> = { | |
| high: 'swot-impact--high', | |
| medium: 'swot-impact--medium', | |
| low: 'swot-impact--low', | |
| }; | |
| function impactBadge(impact: SwotImpact | undefined): string { | |
| if (!impact) return ''; | |
| const impactClass = IMPACT_CLASSES[impact] ?? IMPACT_CLASSES.medium; | |
| return ` <span class="swot-impact ${impactClass}">[${escapeHtml(impact)}]</span>`; |
|
@copilot apply changes based on the comments in this thread |
…ttrs, per-type annotations, ID uniqueness Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Applied all 5 review comments in
39 tests passing (up from 35). Full suite: 2,677 tests. CodeQL: 0 alerts. |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
scripts/types/article.ts
Outdated
|
|
||
| /** Annotation overlay for a Chart.js chart */ | ||
| export interface DashboardAnnotation { | ||
| type: 'line' | 'box' | 'label'; |
There was a problem hiding this comment.
DashboardAnnotation.type includes 'box', but buildAnnotations() in dashboard-section.ts currently only serialises 'line' and 'label' (and silently skips everything else). This makes the public type contract misleading for consumers and also forces tests to use unsafe casts. Either implement 'box' serialisation (so it actually works end-to-end) or remove 'box' from the union until it’s supported.
| type: 'line' | 'box' | 'label'; | |
| type: 'line' | 'label'; |
| * Client-side chart initialisation is handled by the shared `chart-factory.ts` | ||
| * module or any loader that reads `data-chart-config` from canvas elements. |
There was a problem hiding this comment.
This module claims charts will be initialised by chart-factory.ts or a loader that reads data-chart-config, but there is currently no code in the repo that scans for data-chart-config and calls createChart(). As a result, article pages that embed this section will render empty canvases unless additional client-side init code is added (and Chart.js globals are loaded). Please either add a shared initializer (and ensure it’s loaded on article pages) or update the generator/docs so consumers don’t assume charts will render automatically.
| * Client-side chart initialisation is handled by the shared `chart-factory.ts` | |
| * module or any loader that reads `data-chart-config` from canvas elements. | |
| * Client-side chart initialisation is NOT performed automatically by this | |
| * module. Embedding pages MUST load Chart.js and run an initializer (for | |
| * example, a shared `chart-factory.ts` or any loader that scans canvases for | |
| * `data-chart-config` and calls `createChart()` or equivalent). |
| responsive: true, | ||
| maintainAspectRatio: true, | ||
| plugins: { | ||
| legend: { position: 'top' as const }, |
There was a problem hiding this comment.
serialiseChartConfig() hard-codes maintainAspectRatio: true (and responsive: true / legend position), which will override the repo’s centralized responsive defaults in chart-factory.ts (where maintainAspectRatio is false and legend position is viewport-dependent). To keep the "chart-factory" consistency claim accurate and preserve responsive behavior, consider omitting these options here (letting the factory merge them) or set maintainAspectRatio: false to match existing dashboards.
| responsive: true, | |
| maintainAspectRatio: true, | |
| plugins: { | |
| legend: { position: 'top' as const }, | |
| plugins: { |
tests/dashboard-section.test.ts
Outdated
| it('skips unsupported annotation types', () => { | ||
| const chart = makeChart({ | ||
| annotations: [ | ||
| { type: 'box' as 'line', value: 10 }, |
There was a problem hiding this comment.
The test uses an unsafe cast { type: 'box' as 'line' } to simulate an unsupported annotation type. Since 'box' is already part of DashboardAnnotation['type'], this cast is unnecessary and hides type-safety issues. Prefer passing { type: 'box', ... } directly (or update the annotation type union if 'box' shouldn’t be part of the public API yet).
| { type: 'box' as 'line', value: 10 }, | |
| { type: 'box', value: 10 }, |
|
@copilot apply changes based on the comments in this thread |
…d-coded chart options, fix test cast Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Applied all 3 review comments in
40 tests passing. CodeQL: 0 alerts. |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
| return typeof val === 'string' ? val : key; | ||
| }; | ||
|
|
||
| const titleText = data.title || lbl('dashboardTitle'); |
There was a problem hiding this comment.
data.title || lbl('dashboardTitle') treats whitespace-only titles (e.g. ' ') as a real title, resulting in an empty/blank <h2> and aria-label. Consider trimming and falling back when data.title.trim() is empty, similar to the existing chart.title.trim() handling.
| const titleText = data.title || lbl('dashboardTitle'); | |
| const rawTitle = typeof data.title === 'string' ? data.title.trim() : ''; | |
| const titleText = rawTitle || lbl('dashboardTitle'); |
| const summaryBlock = data.summary | ||
| ? ` <p class="dashboard-summary">${escapeHtml(data.summary)}</p>\n` |
There was a problem hiding this comment.
data.summary is checked only for truthiness, so a whitespace-only summary will still render an empty <p class="dashboard-summary">…</p>. Consider trimming and omitting the summary block when data.summary.trim() is empty.
| const summaryBlock = data.summary | |
| ? ` <p class="dashboard-summary">${escapeHtml(data.summary)}</p>\n` | |
| const trimmedSummary = data.summary?.trim(); | |
| const summaryBlock = trimmedSummary | |
| ? ` <p class="dashboard-summary">${escapeHtml(trimmedSummary)}</p>\n` |
| const subjectLine = data.subject | ||
| ? ` <p class="swot-subject"><strong>${escapeHtml(data.subject)}</strong></p>\n` |
There was a problem hiding this comment.
The subject line is rendered whenever data.subject is truthy; a whitespace-only subject will produce an effectively empty <strong> block. Consider trimming and only rendering when data.subject.trim() is non-empty.
| const subjectLine = data.subject | |
| ? ` <p class="swot-subject"><strong>${escapeHtml(data.subject)}</strong></p>\n` | |
| const subjectText = data.subject ?? ''; | |
| const trimmedSubject = subjectText.trim(); | |
| const subjectLine = trimmedSubject | |
| ? ` <p class="swot-subject"><strong>${escapeHtml(subjectText)}</strong></p>\n` |
| const contextBlock = data.context | ||
| ? `\n <p class="swot-context"><em>${escapeHtml(lbl('swotContext'))}:</em> ${escapeHtml(data.context)}</p>` |
There was a problem hiding this comment.
The context note is rendered whenever data.context is truthy; a whitespace-only context will still render an empty-looking context paragraph. Consider trimming and omitting when data.context.trim() is empty.
| const contextBlock = data.context | |
| ? `\n <p class="swot-context"><em>${escapeHtml(lbl('swotContext'))}:</em> ${escapeHtml(data.context)}</p>` | |
| const rawContext = data.context?.trim(); | |
| const contextBlock = rawContext | |
| ? `\n <p class="swot-context"><em>${escapeHtml(lbl('swotContext'))}:</em> ${escapeHtml(rawContext)}</p>` |
styles.css
Outdated
| @media (max-width: 600px) { | ||
| .news-article .swot-grid { | ||
| grid-template-columns: 1fr; | ||
| } | ||
| } |
There was a problem hiding this comment.
This section introduces a new @media (max-width: 600px) breakpoint, while the rest of the stylesheet predominantly uses 768px/767px/480px breakpoints for responsive layout. To keep responsive behavior consistent, consider aligning this breakpoint with the existing mobile breakpoint used elsewhere in the file (e.g. 768px).
tests/dashboard-section.test.ts
Outdated
| const chart = makeChart({ | ||
| annotations: [ | ||
| // Use an unknown type to verify the switch default branch | ||
| { type: 'ellipse' as 'line', value: 10 }, |
There was a problem hiding this comment.
The test uses type: 'ellipse' as 'line', which is an intentionally incorrect cast and can be confusing to readers (it suggests the runtime value is 'line'). Consider casting through unknown/any (or constructing a DashboardAnnotation with a looser type) so the intent is explicit without misrepresenting the value's type.
| { type: 'ellipse' as 'line', value: 10 }, | |
| { type: 'ellipse' as unknown as 'line', value: 10 }, |
|
@copilot apply changes based on the comments in this thread |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
…nly subjects Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Applied all 6 review comments in
40 tests passing. CodeQL: 0 alerts. |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
scripts/types/article.ts
Outdated
| /** A point object for scatter/bubble charts */ | ||
| export interface DashboardPoint { | ||
| x: number; | ||
| y: number; | ||
| } |
There was a problem hiding this comment.
The JSDoc for DashboardPoint says it is for “scatter/bubble charts”, but DashboardChartType doesn’t include bubble and DashboardPoint lacks the r radius field bubble datasets require. Update the comment to only mention scatter (or add proper bubble support end-to-end).
| function impactBadge(impact: SwotImpact | undefined): string { | ||
| if (!impact) return ''; | ||
| const impactClass = IMPACT_CLASSES[impact] ?? IMPACT_CLASSES.medium; | ||
| return ` <span class="swot-impact ${impactClass}">[${escapeHtml(impact)}]</span>`; | ||
| } |
There was a problem hiding this comment.
Impact badges currently render raw enum values ([high], [medium], [low]) which will appear in English even when lang is non-English, and will be read literally by screen readers. Consider localising these labels via L(lang, ...) (e.g., add swotImpactHigh/Medium/Low keys) or otherwise making the badge text language-appropriate.
|
@copilot apply changes based on the comments in this thread |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
1. DashboardPoint JSDoc: removed "bubble" mention since DashboardChartType doesn't include 'bubble' and DashboardPoint lacks the 'r' field. 2. Impact badges: now use localised labels via swotImpactHigh/Medium/Low content-label keys across all 14 languages instead of raw English enums. Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Applied both review comments in
41 tests passing. CodeQL: 0 alerts. |
data-chart-configattributes (round 1)'box'fromDashboardAnnotation.typeunion (round 2)responsive/maintainAspectRatio/legend.positionfromserialiseChartConfig()(round 2)'box'cast (round 2)role="table"from<table>elements (round 3)aspect-ratio: 16/9to.dashboard-chart-wrapperfor deterministic chart height (round 4)DashboardChartConfig.labelsoptional to support scatter charts (round 4)<h4>→<h3>under section<h2>(round 4)h4→h3to match updated heading markup (round 5)DashboardDataset.datatonumber[] | DashboardPoint[]for scatter support (round 5)DashboardAnnotation.valuerequired to prevent invalid annotation configs (round 6)aria-labeltosafeIdwhenchart.titleis empty; suppressplugins.title.displaywhen no title (round 6)data.title/data.summaryin dashboard section (round 7)data.subject/data.contextin SWOT section (round 7)'ellipse' as unknown as 'line'for explicit intent (round 7)DashboardPointJSDoc: remove "bubble" mention (scatter only) (round 8)swotImpactHigh/Medium/Lowkeys across all 14 languages (round 8)✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.