From c098d8543769e48483e56a363b0c7582f4c2f79a Mon Sep 17 00:00:00 2001 From: Cirdes Date: Thu, 29 Oct 2026 11:59:39 -0300 Subject: [PATCH 1/4] [Bug Fix] Chart: read theme colors as full CSS values getThemeColor split each token on spaces and wrapped it in hsl(), which dates from when the tokens were HSL triples. The theme now uses oklch(...), so it produced strings like hsl(oklch(20.5%, 0, 0)). The canvas rejects them and Chart.js drew everything in black, which made bar and line charts nearly invisible in dark mode. Pass the token value through as-is. The legend borderRadius also read --radius through getThemeColor; use a fixed 2px instead, matching the rounded-[2px] legend indicator in shadcn's chart. Co-Authored-By: Claude Opus 5.5 --- gem/lib/ruby_ui/chart/chart_controller.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/gem/lib/ruby_ui/chart/chart_controller.js b/gem/lib/ruby_ui/chart/chart_controller.js index 18034447f..043620fd9 100644 --- a/gem/lib/ruby_ui/chart/chart_controller.js +++ b/gem/lib/ruby_ui/chart/chart_controller.js @@ -32,10 +32,9 @@ export default class extends Controller { this.setDefaultColorsForChart() } + // Theme tokens hold complete CSS colors (e.g. oklch(...)), which the canvas accepts as-is getThemeColor(name) { - const color = getComputedStyle(document.documentElement).getPropertyValue(`--${name}`) - const [hue, saturation, lightness] = color.split(' ') - return `hsl(${hue}, ${saturation}, ${lightness})` + return getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim() } defaultThemeColor() { @@ -66,7 +65,7 @@ export default class extends Controller { Chart.defaults.plugins.legend.labels.boxHeight = 12 Chart.defaults.plugins.legend.labels.borderWidth = 0 Chart.defaults.plugins.legend.labels.useBorderRadius = true - Chart.defaults.plugins.legend.labels.borderRadius = this.getThemeColor('radius') + Chart.defaults.plugins.legend.labels.borderRadius = 2 // px, matches shadcn's rounded-[2px] legend indicator } // Function to refresh the chart From 3f5311179cc6f0ce2f398d0c85f0ed97e65f3fd5 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Thu, 29 Oct 2026 13:38:53 -0300 Subject: [PATCH 2/4] [Bug Fix] Chart: color datasets with --chart-1..5 by default Datasets defaulted to a background fill with a primary border, so every series looked the same. Color dataset i with --chart-(i % 5 + 1), as shadcn's chart examples do; pie, doughnut and polar area charts color each slice instead. Colors set on a dataset still win. Docs: explain the default and add a multiple datasets example. Co-Authored-By: Claude Opus 5.5 --- docs/app/views/docs/chart.rb | 25 +++++++++++++++++++ gem/lib/ruby_ui/chart/chart_controller.js | 29 ++++++++++++++++++----- gem/lib/ruby_ui/chart/chart_docs.rb | 25 +++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/app/views/docs/chart.rb b/docs/app/views/docs/chart.rb index 62d032e63..233ecb6fc 100644 --- a/docs/app/views/docs/chart.rb +++ b/docs/app/views/docs/chart.rb @@ -17,6 +17,14 @@ def view_template Heading(level: 2) { "Usage" } + Text do + plain "Datasets are colored with the " + InlineCode { "--chart-1" } + plain " to " + InlineCode { "--chart-5" } + plain " theme variables, in order. Pie, doughnut and polar area charts color each slice instead. Colors set on a dataset take precedence." + end + render Docs::VisualCodeExample.new(title: "Bar Chart", context: self) do <<~RUBY options = { @@ -42,6 +50,23 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Multiple datasets", context: self) do + <<~RUBY + options = { + type: 'bar', + data: { + labels: ['January', 'February', 'March', 'April', 'May', 'June'], + datasets: [ + { label: 'Desktop', data: [186, 305, 237, 73, 209, 214] }, + { label: 'Mobile', data: [80, 200, 120, 190, 130, 140] } + ] + }, + } + + Chart(options: options) + RUBY + end + render Docs::VisualCodeExample.new(title: "Line Graph", context: self) do <<~RUBY options = { diff --git a/gem/lib/ruby_ui/chart/chart_controller.js b/gem/lib/ruby_ui/chart/chart_controller.js index 043620fd9..e0af0d3a8 100644 --- a/gem/lib/ruby_ui/chart/chart_controller.js +++ b/gem/lib/ruby_ui/chart/chart_controller.js @@ -37,11 +37,28 @@ export default class extends Controller { return getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim() } - defaultThemeColor() { + // Cycles through the --chart-1..5 theme tokens, like shadcn's chart examples + getChartColor(index) { + return this.getThemeColor(`chart-${(index % 5) + 1}`) + } + + // Pie-like charts color each slice; the others color each dataset + defaultThemeColor(dataset, index) { + const type = dataset.type || this.optionsValue.type + + if (['pie', 'doughnut', 'polarArea'].includes(type)) { + return { + backgroundColor: (dataset.data || []).map((_, i) => this.getChartColor(i)), + borderColor: this.getThemeColor('background'), + borderWidth: 1, + } + } + + const color = this.getChartColor(index) return { - backgroundColor: this.getThemeColor('background'), - hoverBackgroundColor: this.getThemeColor('accent'), - borderColor: this.getThemeColor('primary'), + backgroundColor: color, + hoverBackgroundColor: color, + borderColor: color, borderWidth: 1, } } @@ -90,9 +107,9 @@ export default class extends Controller { ...this.optionsValue, data: { ...this.optionsValue.data, - datasets: this.optionsValue.data.datasets.map((dataset) => { + datasets: this.optionsValue.data.datasets.map((dataset, index) => { return { - ...this.defaultThemeColor(), + ...this.defaultThemeColor(dataset, index), ...dataset, } }) diff --git a/gem/lib/ruby_ui/chart/chart_docs.rb b/gem/lib/ruby_ui/chart/chart_docs.rb index 6aaf88ed0..677f91c33 100644 --- a/gem/lib/ruby_ui/chart/chart_docs.rb +++ b/gem/lib/ruby_ui/chart/chart_docs.rb @@ -17,6 +17,14 @@ def view_template Heading(level: 2) { "Usage" } + Text do + plain "Datasets are colored with the " + InlineCode { "--chart-1" } + plain " to " + InlineCode { "--chart-5" } + plain " theme variables, in order. Pie, doughnut and polar area charts color each slice instead. Colors set on a dataset take precedence." + end + render Docs::VisualCodeExample.new(title: "Bar Chart", context: self) do <<~RUBY options = { @@ -42,6 +50,23 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Multiple datasets", context: self) do + <<~RUBY + options = { + type: 'bar', + data: { + labels: ['January', 'February', 'March', 'April', 'May', 'June'], + datasets: [ + { label: 'Desktop', data: [186, 305, 237, 73, 209, 214] }, + { label: 'Mobile', data: [80, 200, 120, 190, 130, 140] } + ] + }, + } + + Chart(options: options) + RUBY + end + render Docs::VisualCodeExample.new(title: "Line Graph", context: self) do <<~RUBY options = { From f26b5f9e0f2a052476869bf149ff96627c41c0b1 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Thu, 29 Oct 2026 13:53:47 -0300 Subject: [PATCH 3/4] [Bug Fix] Chart: support object data in pie slice colors Chart.js accepts keyed object data ({ a: 10, b: 20 }); calling .map on it threw while building the default slice colors. Use Object.values so array and object data both get one color per value. Co-Authored-By: Claude Opus 5.5 --- gem/lib/ruby_ui/chart/chart_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gem/lib/ruby_ui/chart/chart_controller.js b/gem/lib/ruby_ui/chart/chart_controller.js index e0af0d3a8..8e99dc3d8 100644 --- a/gem/lib/ruby_ui/chart/chart_controller.js +++ b/gem/lib/ruby_ui/chart/chart_controller.js @@ -48,7 +48,7 @@ export default class extends Controller { if (['pie', 'doughnut', 'polarArea'].includes(type)) { return { - backgroundColor: (dataset.data || []).map((_, i) => this.getChartColor(i)), + backgroundColor: Object.values(dataset.data || {}).map((_, i) => this.getChartColor(i)), borderColor: this.getThemeColor('background'), borderWidth: 1, } From ced33e439240700f18995d7acb9fd467896406d8 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Thu, 29 Oct 2026 13:59:04 -0300 Subject: [PATCH 4/4] [Bug Fix] Rebuild MCP registry for Chart changes Picks up the updated chart controller and the new "Multiple datasets" docs example. Co-Authored-By: Claude Opus 5.5 --- mcp/data/registry.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mcp/data/registry.json b/mcp/data/registry.json index c5505d648..e9d638b2c 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -728,7 +728,7 @@ }, { "path": "chart_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\nimport Chart from 'chart.js/auto'\n\n// Chart controller\nexport default class extends Controller {\n static values = {\n options: {\n type: Object,\n default: {},\n }\n }\n\n // Function to initialize the chart when the controller is connected\n connect() {\n this.initDarkModeObserver()\n this.initChart()\n }\n\n disconnect() {\n this.darkModeObserver?.disconnect()\n this.chart?.destroy()\n }\n\n // Function to initialize the chart\n initChart() {\n this.setColors()\n const ctx = this.element.getContext('2d');\n this.chart = new Chart(ctx, this.mergeOptionsWithDefaults());\n }\n\n setColors() {\n this.setDefaultColorsForChart()\n }\n\n getThemeColor(name) {\n const color = getComputedStyle(document.documentElement).getPropertyValue(`--${name}`)\n const [hue, saturation, lightness] = color.split(' ')\n return `hsl(${hue}, ${saturation}, ${lightness})`\n }\n\n defaultThemeColor() {\n return {\n backgroundColor: this.getThemeColor('background'),\n hoverBackgroundColor: this.getThemeColor('accent'),\n borderColor: this.getThemeColor('primary'),\n borderWidth: 1,\n }\n }\n\n // Function to set chart default colors\n setDefaultColorsForChart() {\n Chart.defaults.color = this.getThemeColor('muted-foreground') // font color\n Chart.defaults.borderColor = this.getThemeColor('border') // border color\n Chart.defaults.backgroundColor = this.getThemeColor('background') // background color\n\n // tooltip colors\n Chart.defaults.plugins.tooltip.backgroundColor = this.getThemeColor('background')\n Chart.defaults.plugins.tooltip.borderColor = this.getThemeColor('border')\n Chart.defaults.plugins.tooltip.titleColor = this.getThemeColor('foreground')\n Chart.defaults.plugins.tooltip.bodyColor = this.getThemeColor('muted-foreground')\n Chart.defaults.plugins.tooltip.borderWidth = 1\n\n // legend\n // options.plugins.legend.labels\n Chart.defaults.plugins.legend.labels.boxWidth = 12\n Chart.defaults.plugins.legend.labels.boxHeight = 12\n Chart.defaults.plugins.legend.labels.borderWidth = 0\n Chart.defaults.plugins.legend.labels.useBorderRadius = true\n Chart.defaults.plugins.legend.labels.borderRadius = this.getThemeColor('radius')\n }\n\n // Function to refresh the chart\n refreshChart() {\n // Destroy the chart if it's a valid Chart.js instance\n this.chart?.destroy()\n // Reinitialize the chart\n this.initChart()\n }\n\n // Function to initialize the dark mode observer\n initDarkModeObserver() {\n this.darkModeObserver = new MutationObserver(() => {\n this.refreshChart()\n })\n this.darkModeObserver.observe(document.documentElement, { attributeFilter: ['class'] })\n }\n\n // Function to merge the options with the defaults\n mergeOptionsWithDefaults() {\n return {\n ...this.optionsValue,\n data: {\n ...this.optionsValue.data,\n datasets: this.optionsValue.data.datasets.map((dataset) => {\n return {\n ...this.defaultThemeColor(),\n ...dataset,\n }\n })\n }\n }\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\nimport Chart from 'chart.js/auto'\n\n// Chart controller\nexport default class extends Controller {\n static values = {\n options: {\n type: Object,\n default: {},\n }\n }\n\n // Function to initialize the chart when the controller is connected\n connect() {\n this.initDarkModeObserver()\n this.initChart()\n }\n\n disconnect() {\n this.darkModeObserver?.disconnect()\n this.chart?.destroy()\n }\n\n // Function to initialize the chart\n initChart() {\n this.setColors()\n const ctx = this.element.getContext('2d');\n this.chart = new Chart(ctx, this.mergeOptionsWithDefaults());\n }\n\n setColors() {\n this.setDefaultColorsForChart()\n }\n\n // Theme tokens hold complete CSS colors (e.g. oklch(...)), which the canvas accepts as-is\n getThemeColor(name) {\n return getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim()\n }\n\n // Cycles through the --chart-1..5 theme tokens, like shadcn's chart examples\n getChartColor(index) {\n return this.getThemeColor(`chart-${(index % 5) + 1}`)\n }\n\n // Pie-like charts color each slice; the others color each dataset\n defaultThemeColor(dataset, index) {\n const type = dataset.type || this.optionsValue.type\n\n if (['pie', 'doughnut', 'polarArea'].includes(type)) {\n return {\n backgroundColor: Object.values(dataset.data || {}).map((_, i) => this.getChartColor(i)),\n borderColor: this.getThemeColor('background'),\n borderWidth: 1,\n }\n }\n\n const color = this.getChartColor(index)\n return {\n backgroundColor: color,\n hoverBackgroundColor: color,\n borderColor: color,\n borderWidth: 1,\n }\n }\n\n // Function to set chart default colors\n setDefaultColorsForChart() {\n Chart.defaults.color = this.getThemeColor('muted-foreground') // font color\n Chart.defaults.borderColor = this.getThemeColor('border') // border color\n Chart.defaults.backgroundColor = this.getThemeColor('background') // background color\n\n // tooltip colors\n Chart.defaults.plugins.tooltip.backgroundColor = this.getThemeColor('background')\n Chart.defaults.plugins.tooltip.borderColor = this.getThemeColor('border')\n Chart.defaults.plugins.tooltip.titleColor = this.getThemeColor('foreground')\n Chart.defaults.plugins.tooltip.bodyColor = this.getThemeColor('muted-foreground')\n Chart.defaults.plugins.tooltip.borderWidth = 1\n\n // legend\n // options.plugins.legend.labels\n Chart.defaults.plugins.legend.labels.boxWidth = 12\n Chart.defaults.plugins.legend.labels.boxHeight = 12\n Chart.defaults.plugins.legend.labels.borderWidth = 0\n Chart.defaults.plugins.legend.labels.useBorderRadius = true\n Chart.defaults.plugins.legend.labels.borderRadius = 2 // px, matches shadcn's rounded-[2px] legend indicator\n }\n\n // Function to refresh the chart\n refreshChart() {\n // Destroy the chart if it's a valid Chart.js instance\n this.chart?.destroy()\n // Reinitialize the chart\n this.initChart()\n }\n\n // Function to initialize the dark mode observer\n initDarkModeObserver() {\n this.darkModeObserver = new MutationObserver(() => {\n this.refreshChart()\n })\n this.darkModeObserver.observe(document.documentElement, { attributeFilter: ['class'] })\n }\n\n // Function to merge the options with the defaults\n mergeOptionsWithDefaults() {\n return {\n ...this.optionsValue,\n data: {\n ...this.optionsValue.data,\n datasets: this.optionsValue.data.datasets.map((dataset, index) => {\n return {\n ...this.defaultThemeColor(dataset, index),\n ...dataset,\n }\n })\n }\n }\n }\n}\n" } ], "dependencies": { @@ -739,13 +739,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Chart", - "docs_markdown": "# Chart\n\nDisplays information in a visual way.\n\n## Introduction\n\n## Usage\n\n### Bar Chart\n\n```ruby\noptions = {\n type: 'bar',\n data: {\n labels: ['Phlex', 'VC', 'ERB'],\n datasets: [{\n label: 'render time (ms)',\n data: [100, 520, 1200],\n }]\n },\n options: {\n indexAxis: 'y',\n scales: {\n y: {\n beginAtZero: true\n }\n },\n },\n}\n\nChart(options: options)\n```\n\n### Line Graph\n\n```ruby\noptions = {\n type: 'line',\n data: {\n labels: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'],\n datasets: [{\n label: 'Github Stars',\n data: [40, 30, 79, 140, 290, 550],\n }]\n },\n options: {\n scales: {\n y: {\n beginAtZero: true\n }\n },\n plugins: {\n legend: {\n display: false\n }\n }\n },\n}\n\nChart(options: options)\n```\n\n### Pie Chart\n\n```ruby\noptions = {\n type: 'pie',\n data: {\n labels: [\n 'Red',\n 'Blue',\n 'Yellow'\n ],\n datasets: [{\n label: 'My First Dataset',\n data: [300, 50, 100],\n backgroundColor: [\n 'rgb(255, 99, 132)',\n 'rgb(54, 162, 235)',\n 'rgb(255, 205, 86)'\n ],\n hoverOffset: 4\n }]\n },\n}\n\nChart(options: options)\n```", + "docs_markdown": "# Chart\n\nDisplays information in a visual way.\n\n## Introduction\n\n## Usage\n\n### Bar Chart\n\n```ruby\noptions = {\n type: 'bar',\n data: {\n labels: ['Phlex', 'VC', 'ERB'],\n datasets: [{\n label: 'render time (ms)',\n data: [100, 520, 1200],\n }]\n },\n options: {\n indexAxis: 'y',\n scales: {\n y: {\n beginAtZero: true\n }\n },\n },\n}\n\nChart(options: options)\n```\n\n### Multiple datasets\n\n```ruby\noptions = {\n type: 'bar',\n data: {\n labels: ['January', 'February', 'March', 'April', 'May', 'June'],\n datasets: [\n { label: 'Desktop', data: [186, 305, 237, 73, 209, 214] },\n { label: 'Mobile', data: [80, 200, 120, 190, 130, 140] }\n ]\n },\n}\n\nChart(options: options)\n```\n\n### Line Graph\n\n```ruby\noptions = {\n type: 'line',\n data: {\n labels: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'],\n datasets: [{\n label: 'Github Stars',\n data: [40, 30, 79, 140, 290, 550],\n }]\n },\n options: {\n scales: {\n y: {\n beginAtZero: true\n }\n },\n plugins: {\n legend: {\n display: false\n }\n }\n },\n}\n\nChart(options: options)\n```\n\n### Pie Chart\n\n```ruby\noptions = {\n type: 'pie',\n data: {\n labels: [\n 'Red',\n 'Blue',\n 'Yellow'\n ],\n datasets: [{\n label: 'My First Dataset',\n data: [300, 50, 100],\n backgroundColor: [\n 'rgb(255, 99, 132)',\n 'rgb(54, 162, 235)',\n 'rgb(255, 205, 86)'\n ],\n hoverOffset: 4\n }]\n },\n}\n\nChart(options: options)\n```", "examples": [ { "title": "Bar Chart", "code": "options = {\n type: 'bar',\n data: {\n labels: ['Phlex', 'VC', 'ERB'],\n datasets: [{\n label: 'render time (ms)',\n data: [100, 520, 1200],\n }]\n },\n options: {\n indexAxis: 'y',\n scales: {\n y: {\n beginAtZero: true\n }\n },\n },\n}\n\nChart(options: options)\n", "language": "ruby" }, + { + "title": "Multiple datasets", + "code": "options = {\n type: 'bar',\n data: {\n labels: ['January', 'February', 'March', 'April', 'May', 'June'],\n datasets: [\n { label: 'Desktop', data: [186, 305, 237, 73, 209, 214] },\n { label: 'Mobile', data: [80, 200, 120, 190, 130, 140] }\n ]\n },\n}\n\nChart(options: options)\n", + "language": "ruby" + }, { "title": "Line Graph", "code": "options = {\n type: 'line',\n data: {\n labels: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'],\n datasets: [{\n label: 'Github Stars',\n data: [40, 30, 79, 140, 290, 550],\n }]\n },\n options: {\n scales: {\n y: {\n beginAtZero: true\n }\n },\n plugins: {\n legend: {\n display: false\n }\n }\n },\n}\n\nChart(options: options)\n",