fix(charts): restyle hover tooltips with soft shadow + larger radius (#2029)

* fix(charts): restyle hover tooltips with soft shadow + larger radius

Match the elevation pattern of DS::Select dropdown
(shadow-lg + shadow-border-xs) and increase radius (rounded-lg →
rounded-xl) + padding (p-2 → p-3) for better breathing. Drops the
hard border in favour of the soft drop shadow.

time_series_chart_controller:
- p-2 → p-3, rounded-lg → rounded-xl
- remove border border-secondary
- add shadow-lg shadow-border-xs (theme-aware drop + border-edge)
- add explicit text-primary (was missing per #2011's drift note)
- add z-50 (matches goal_projection + sankey controllers)

sankey_chart_controller:
- bg-gray-700 text-white → bg-container text-primary (theme-aware;
  was broken in light mode after dark-bg flip)
- p-2 rounded → p-3 rounded-xl
- add shadow-lg shadow-border-xs
- add font-sans for consistency with the other chart tooltips
- add privacy-sensitive class (was missing — sankey money values
  were rendered in the clear with privacy mode on)

DS::Tooltip (icon-trigger help, bg-inverse) is intentionally a
different primitive and is not touched.

Refs #2011 — className consolidation into a shared module is tracked
separately and intentionally not closed by this PR; the new string
applies to two of the three call sites today (time_series, sankey).
The third (goal_projection_chart_controller) lives on the
feat/goals-v2-architecture branch and will adopt the same string when
goals v2 merges.

* fix(charts): align every chart tooltip on the borderless soft-shadow card

One visual contract for all three D3 tooltip surfaces, matching the
design reference: p-4, rounded-2xl, shadow-xl, no edge ring in light
mode. Dark mode keeps a 1px alpha-white ring since a shadow alone
disappears against dark surfaces.

- goal_projection_chart_controller drops its hand-copied class string
  (it still carried the old bordered recipe — the drift this util
  exists to prevent) and builds its two lines through the shared
  factory: secondary date line, tabular value line.
- New content conventions exported alongside the container contract:
  context line = text-xs text-secondary, values = font-medium
  tabular-nums. Time-series and sankey adopt them.
- Sankey node titles now escape before .html(); user-named categories
  were previously interpolated raw into the tooltip markup.

* fix(charts): match the tooltip surface to the design reference exactly

The previous pass approximated the reference with utility guesses
(rounded-2xl, p-4, shadow-xl, dark ring). The actual spec is a hairline
border ring composed with a soft 0 8px 24px drop shadow, 10px radius,
12x14 padding, and an 80ms left/top glide. Tailwind shadow utilities
can't compose a ring with a custom drop shadow, so the surface moves
into the design system as .chart-tooltip (theme-aware: dark swaps the
ring to alpha-white and lets it carry the edge).

Money/numeric figures also pick up the reference's mono treatment:
font-mono + tabular-nums on every value across time-series, sankey,
and goal-projection, so digits don't jitter while the scrubber moves.

* fix(charts): tighten tooltip padding to 10x12

* feat(charts): give sankey and goal tooltips their missing context rows

Chart tooltips answer three stacked questions: context (what am I
looking at), value (how much), relation (vs what). Time-series already
had all three; the other two were missing rows.

- Sankey links showed a bare "$X (Y%)" with no indication of which
  flow was hovered. Links now lead with a swatch-dotted
  "Source → Target" context line; nodes get the same dot + name
  treatment, tying the card to the ribbon color. The context builder
  escapes node names centrally (they're user-named categories).
- Goal projection adds a tertiary relation line — "52% of $20K
  target" — computed from the payload the chart already carries, for
  both the saved and projected segments. Hidden when the goal has no
  positive target. Template is i18n-wired like the existing tooltip
  strings (goals.show.projection.tooltip_target_relation).

Verified with Playwright against the running app: all three surfaces
pass computed-style and content assertions.

* fix(charts): drop the color dot from sankey tooltip context

The hover highlight on the diagram already identifies the ribbon; the
swatch repeated it inside the card. Names alone keep the context line
quieter.

* fix(charts): use the app's sans money treatment in tooltips, not mono

font-mono in this codebase marks code, keys, and admin surfaces; money
is sans + tabular-nums everywhere else (cards, KPIs, tables). Keep the
tabular figures for scrub stability, drop the mono.

* fix(charts): address review — glide opt-in, no shadowed var, truncate cap

- The 80ms left/top transition moved out of .chart-tooltip: it eased the
  snap-positioned goal tooltip but made cursor-following tooltips
  (sankey, time-series) trail the pointer by a frame. Goal projection
  opts back in via inline style; the component comment documents the
  split.
- setRelation reuses _draw()'s targetAmount const instead of declaring
  a local 'target' that shadowed the target-date const.
- Sankey context line gets max-w-64 so truncate has a constraint to
  fire against on deep flows.
- Component comment now says 10x12 padding, matching the declaration.

* Revert `schema.rb` changes

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
This commit is contained in:
Guillem Arias Fauste
2026-06-08 21:58:37 +02:00
committed by GitHub
parent 98791de851
commit 6d27285c03
7 changed files with 138 additions and 21 deletions

View File

@@ -445,7 +445,16 @@ export default class extends Controller {
linkPaths
.on("mouseenter", (event, d) => {
applyHover([d]);
this.#showTooltip(event, d.value, d.percentage);
// A link is a flow between two named nodes — without the names the
// value floats context-free (the old tooltip showed only "$X (Y%)").
this.#showTooltip(
event,
d.value,
d.percentage,
this.#tooltipContext(
`${this.#esc(d.source.name)}${this.#esc(d.target.name)}`,
),
);
})
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("mouseleave", () => {
@@ -466,7 +475,12 @@ export default class extends Controller {
(l) => l.source === d || l.target === d,
);
applyHover(connectedLinks);
this.#showTooltip(event, d.value, d.percentage, d.name);
this.#showTooltip(
event,
d.value,
d.percentage,
this.#tooltipContext(this.#esc(d.name)),
);
})
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("click", (event, d) => {
@@ -490,7 +504,12 @@ export default class extends Controller {
(l) => l.source === d || l.target === d,
);
applyHover(connectedLinks);
this.#showTooltip(event, d.value, d.percentage, d.name);
this.#showTooltip(
event,
d.value,
d.percentage,
this.#tooltipContext(this.#esc(d.name)),
);
})
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("click", (event, d) => {
@@ -517,12 +536,32 @@ export default class extends Controller {
.style("pointer-events", "none");
}
#showTooltip(event, value, percentage, title = null) {
// Node names are user-named categories; escape anything interpolated into
// .html() (the previous code injected them raw).
#esc(s) {
return String(s).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
}
// Context line shared by node and link tooltips: the (escaped) name(s) of
// what's hovered. No color swatch — the hover highlight on the diagram
// itself already says which ribbon the card belongs to.
#tooltipContext(label) {
// max-w-64 gives truncate a constraint to fire against — an absolute
// tooltip otherwise grows to fit and never ellipsizes deep flows.
return `<div class="max-w-64 text-xs text-secondary mb-1 truncate">${label}</div>`;
}
#showTooltip(event, value, percentage, contextHtml = null) {
if (!this.tooltip) this.#createTooltip();
const content = title
? `${title}<br/>${this.#formatCurrency(value)} (${percentage || 0}%)`
: `${this.#formatCurrency(value)} (${percentage || 0}%)`;
const valueLine = `<span class="font-medium tabular-nums">${this.#formatCurrency(value)}</span> <span class="text-secondary">(${percentage || 0}%)</span>`;
const content = contextHtml
? `${contextHtml}<div>${valueLine}</div>`
: valueLine;
const isInDialog = !!this.element.closest("dialog");
const x = isInDialog ? event.clientX : event.pageX;