diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index cefddedeb..3caaf4121 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -1,5 +1,10 @@ import { Controller } from "@hotwired/stimulus"; import * as d3 from "d3"; +import { + createChartTooltip, + CHART_TOOLTIP_CONTEXT_CLASSES, + CHART_TOOLTIP_VALUE_CLASSES, +} from "utils/chart_tooltip"; // Projection chart for a goal. Renders: // - Saved area + line from goal creation → today (solid) @@ -439,10 +444,15 @@ export default class extends Controller { // clobber a stylesheet `position: fixed/sticky/absolute` with our // own `relative`. Read the computed style instead. if (getComputedStyle(root).position === "static") root.style.position = "relative"; - const tooltip = document.createElement("div"); - tooltip.className = "bg-container text-primary text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none z-50 privacy-sensitive"; - tooltip.style.display = "none"; - root.appendChild(tooltip); + // Shared visual contract (utils/chart_tooltip) — this used to be a + // hand-copied class string that drifted from the other charts the moment + // the contract changed. + const tooltip = createChartTooltip(root); + const tooltipDate = document.createElement("div"); + tooltipDate.className = CHART_TOOLTIP_CONTEXT_CLASSES; + const tooltipValue = document.createElement("div"); + tooltipValue.className = CHART_TOOLTIP_VALUE_CLASSES; + tooltip.replaceChildren(tooltipDate, tooltipValue); const overlay = svg .append("rect") @@ -485,7 +495,7 @@ export default class extends Controller { const hoverX = x(hoverDate); crosshair.attr("x1", hoverX).attr("x2", hoverX).style("display", null); - const lines = [dateFmt(hoverDate)]; + tooltipDate.textContent = dateFmt(hoverDate); if (future) { // Projection segment: interpolate along the dashed line; saved dot @@ -494,7 +504,7 @@ export default class extends Controller { const projValue = currentAmount + tFrac * (projectionEnd - currentAmount); hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null); hoverSavedDot.style("display", "none"); - lines.push(this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency))); + tooltipValue.textContent = this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency)); } else { // Saved segment: hoverDate is already snapped to nearest savedSeries // entry above, so reuse that entry directly instead of running @@ -502,11 +512,9 @@ export default class extends Controller { const savedPoint = savedSeries.find((p) => p.date.getTime() === hoverDate.getTime()) || savedSeries[savedSeries.length - 1]; hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null); hoverProjDot.style("display", "none"); - lines.push(this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency))); + tooltipValue.textContent = this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency)); } - tooltip.textContent = lines.join("\n"); - tooltip.style.whiteSpace = "pre"; tooltip.style.display = "block"; const tipRect = tooltip.getBoundingClientRect(); const left = Math.min(width - tipRect.width - 4, Math.max(4, xPos + 12)); diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index b7d12c036..2219ef4b6 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -520,9 +520,18 @@ export default class extends Controller { #showTooltip(event, value, percentage, title = null) { if (!this.tooltip) this.#createTooltip(); + // Node titles are user-named categories; escape them since this goes + // through .html() (the previous interpolation injected them raw). + const esc = (s) => + String(s).replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); + const valueLine = `${this.#formatCurrency(value)} (${percentage || 0}%)`; const content = title - ? `${title}
${this.#formatCurrency(value)} (${percentage || 0}%)` - : `${this.#formatCurrency(value)} (${percentage || 0}%)`; + ? `
${esc(title)}
${valueLine}
` + : valueLine; const isInDialog = !!this.element.closest("dialog"); const x = isInDialog ? event.clientX : event.pageX; diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 1bf6f8d89..0628d0315 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -385,11 +385,11 @@ export default class extends Controller { _tooltipTemplate(datum) { return ` -
+
${datum.date_formatted}
-
+
${this._getTrendIcon(datum)}
@@ -400,7 +400,7 @@ export default class extends Controller { datum.trend.value === 0 ? `` : ` - + ${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted}) ` diff --git a/app/javascript/utils/chart_tooltip.js b/app/javascript/utils/chart_tooltip.js index e0dad3199..690f7802f 100644 --- a/app/javascript/utils/chart_tooltip.js +++ b/app/javascript/utils/chart_tooltip.js @@ -11,8 +11,20 @@ // Not to be confused with DS::Tooltip — that is the info-icon hint primitive // (bg-inverse, aria-describedby, anchored to a static trigger). This is a // data-card surface created and updated inside D3 handler code. +// Visual target: the borderless soft-shadow card from the design reference — +// generous padding, large radius, no edge ring in light mode (the shadow alone +// defines the surface). Dark mode keeps a 1px alpha ring because a shadow is +// nearly invisible against dark surfaces and the card would otherwise melt +// into the chart background. export const CHART_TOOLTIP_CLASSES = - "bg-container text-primary text-sm font-sans absolute p-3 rounded-xl shadow-lg shadow-border-xs pointer-events-none z-50 privacy-sensitive"; + "bg-container text-primary text-sm font-sans absolute p-4 rounded-2xl shadow-xl theme-dark:ring-1 theme-dark:ring-alpha-white-200 pointer-events-none z-50 privacy-sensitive"; + +// Content conventions (kept here so the controllers stay aligned): +// - context line (date / node title): `text-xs text-secondary mb-1` +// - value figures: `font-medium tabular-nums`, secondary parentheticals in +// `text-secondary` +export const CHART_TOOLTIP_CONTEXT_CLASSES = "text-xs text-secondary mb-1"; +export const CHART_TOOLTIP_VALUE_CLASSES = "font-medium tabular-nums"; // Convenience factory for the raw-DOM idiom (no d3.select). Creates a hidden // tooltip div carrying the shared contract and appends it to `parent`.