Uh oh!
There was an error while loading. Please reload this page.
Add Chart, StatusDot and ConnectAccountButton core components - #669
Merged
Conversation
Server-rendered SVG chart primitives (area/step chart with now-marker, sparkline, bars) — zero JS, currentColor theme-aware, LiveView-native. StatusDot: generic semantic dot+label (online/offline/live states). ConnectAccountButton: OAuth-popup account-linking pattern for the Integrations system. stat_card gains optional value_color for values whose colour carries information. Extracted from NordSwitch, which will adopt them on release; handoff notes for testing in dev_docs.
The three new components shipped untested by design — the handoff asked
for exactly this pass. 121 component tests later, four real defects:
- bar_chart scaled from max(value), so a negative value produced a
NEGATIVE rect height. SVG discards those outright, so the bar silently
vanished rather than rendering below a baseline, and an all-negative
series divided by the 1.0e-9 floor and threw the geometry to
astronomical numbers. Bars are measured from a zero baseline now, which
is also what makes mixed-sign data readable.
- A single data point produced a bare `M x,y` path, which is valid and
paints nothing. One datum is a real case (the first slot of the day, a
metric with one sample), so it holds its value across the x range —
the same reading step mode already gave it.
- The charts carried `role="img"` with no accessible name, which is an
unlabelled graphic to a screen reader. There is an optional `label`
now, rendered as both `<title>` and `aria-label`; unlabelled charts are
explicitly marked decorative instead.
- connect_account_button used an inline onclick. Beyond being
CSP-hostile in a kit that removed inline handlers everywhere else, its
`return false` cancelled navigation unconditionally — including when
the popup was blocked, which is precisely when the documented
"falls back to normal navigation" was supposed to save it. The button
simply did nothing. It is a ConnectAccountPopup hook now, which
preventDefaults only once window.open has returned a window, leaves
modifier-clicks alone, and centres the popup on the active monitor.
Two hardening changes on top: the button only accepts a local path,
since the popup deliberately keeps `window.opener` for the callback to
reload the opener, and its derived DOM id is slugified so a window name
with spaces can't produce an id LiveView cannot look up. stat_card's
value_color strips `;` so a computed colour cannot append a second
declaration, and the attribute is spread conditionally because HEEx
renders `style={nil}` as an empty `style=""`.A reversed domain like x_domain={{10, 0}} drove the span negative, and the
1.0e-9 floor then turned that into coordinates around 1.0e12 — the chart
disappeared off-canvas rather than drawing. Both axes normalise now.
Unsorted data rendered as a zigzag that reads like a bug in the chart
rather than in the caller's data; a line chart only goes left-to-right,
so the series is sorted by x defensively.The hook's real logic — which clicks to intercept, and where to place the popup — was unreachable from the Elixir suite. Both are now pure functions exported for Node, covering the cases that actually bite: modifier-clicks and middle-clicks must stay the user's own (intercepting them breaks open-in-new-tab), a blocked popup must fall through to plain navigation, and the geometry must centre on the monitor the browser window is on rather than flinging the popup to the primary display. Adds test/js + a `mix test.js` alias wired into precommit, mirroring the harness phoenix_kit_dashboards already uses. It skips itself when node isn't installed rather than failing precommit over optional tooling.
External review of the new components, applied. The two that mattered:
One bad point took the whole page down. `data={[{0, nil}, …]}` raised an
uncaught ArithmeticError mid-render, and so did any `Decimal` — which is
what Ecto hands you for money, in a kit that ships a billing module.
Points are normalised now: tuples or `%{x:, y:}` maps, Decimals converted,
anything non-numeric dropped, and the `:empty` slot when nothing usable
survives. A drawing primitive should degrade, not crash the LiveView.
Every unconfigured connect_account_button shared one DOM id, because
`window_name` defaulted to a constant. Two buttons on an integrations page
— the moduledoc's own example — is exactly the colliding case, and
phx-hook requires uniqueness. The default now derives from `href`, which
is already required to be local and differs per provider, so distinct
buttons get distinct ids and distinct popups for free.
Accessibility: status_dot conveyed its state through colour alone, silent
to a screen reader and indistinguishable to red/green colour-blind users.
Unlabelled dots now carry a visually-hidden state name kept in step with
the colour. (The previous test asserted that silence was correct, which is
how it survived the first pass.) The pulse animation respects
prefers-reduced-motion.
API, while it is still free to change — nothing consumes these yet:
area_chart is line_chart (`area_chart area={false}` was a line chart with
a misleading name), the a11y attr is `aria_label` so it stops colliding
with bar data's own `label:` key, the hook is the generic `PopupLink`
since nothing about it is OAuth-specific, and all three charts accept
`:rest`. Bars gained native `<title>` tooltips, a zero baseline when data
spans both signs, per-datum `class`, a rendered `id`, and labels sized to
their own slot instead of drifting to the container edges under
`justify-between`. Gridlines got the `vector-effect` the line already had.
A single sparkline value draws a flat line rather than nothing.
The hook refuses cross-origin hrefs itself rather than trusting the Elixir
guard — the bundle is copied verbatim into hosts and the hook name is
public — and treats an already-closed popup as blocked.
Also corrected a wrong security claim in the moduledoc: the local-path
check is NOT what keeps `window.opener` from a third party. The popup
navigates on to the provider and the opener reference survives, so the
provider's origin can reach `opener.location` — reverse tabnabbing. The
doc now says what actually mitigates it (COOP plus postMessage).A computed value surfaced float noise straight into the tooltip: 0.1 + 0.2 rendered as "0.30000000000000004". Display is rounded and trailing zeros dropped, so whole numbers read as whole numbers and money reads as money; the geometry keeps full precision.
The handoff said "deliberately untested" and asked for a review pass; this records what that pass found and changed, what was deliberately not addressed, and where the next consumers are.
Second review sweep. The two worst findings were mine, both introduced by
round 1:
`normalize_domain/1` fell back to a constant `{0, 1}` for an unusable
bound — which recreated, for a different input, exactly the bug the
comment above it claims to fix. `x_domain={{0, nil}}` over a 0..1440
series produced coordinates around 691,200: finite, so nothing caught it,
and off-canvas, so the chart was simply gone. It falls back to the data's
own domain now.
The documented sizing contract did not work, and the moduledoc example
was itself the failing case: the wrapper had no height, so the svg's
`h-full` resolved against an auto-height parent and the chart fell back
to its viewBox ratio. The wrapper carries the height now, and bar_chart
lays out as a column so an optional label row can't overflow the box the
caller gave it.
Also from the sweep:
- Dropping a bad sparkline sample re-spaced the axis, so removing one
point silently changed the SHAPE of the line rather than leaving a gap.
Points are indexed before the reject, so survivors keep their x.
- A flat sparkline sat on the floor while the line chart centred the same
data — a steady metric read as "at its minimum".
- The step tail emitted the viewBox width unconditionally, so data
overshooting an explicit x_domain drew forward past the edge and then
back leftward across the chart.
- `label=""` — an ordinary result of `label={@device.name}` — left a dot
with no accessible name at all: the empty string is truthy, so it
rendered an empty visible span AND suppressed the hidden one.
- bar_chart accepted only `%{value:}` maps, silently rendering a blank box
for the tuple and string-keyed shapes line_chart takes.
- An all-negative series drew no zero line, though its baseline sits at
the top where nothing implies it; an all-zero series rendered nothing at
all, since a zero-height rect paints nothing.
- Tooltip rounding collapsed small magnitudes to "0" — a rate or a
sub-cent price. There is a `value_format` fun now, which is also the
only sane answer for units, currency and locale.
- The popup clamped its position to 0, throwing it back to the primary
display for anyone whose browser sits on a monitor left of or above it.
- The COOP advice was wrong: `same-origin-allow-popups` is the value that
KEEPS the opener relationship. The doc now says the only real fix is to
stop using `window.opener` — open with noopener and let the callback
broadcast on PubSub.
- status_dot's colour and announced word were separate lookup tables free
to drift; one resolver returns both. The vocabulary is gettext'd and
overridable via `state_label`.
- stat_card's `:rounded` attr had been declared and documented for a long
time while the class was hardcoded.
Seven tests passed for the wrong reason and now don't: the finite-number
guard skipped "NaN" entirely (its regex excluded N and I) and contributed
no numbers at all for a sparkline; the step assertion was satisfied by a
non-step render; `=~ "error"` was satisfied by the `bg-error` class; and
the hook's blocked-popup branch — the entire point of the round-1 rewrite
— had no coverage until now.Wiring up stat_card's long-dead :rounded attr introduced a subtler bug
than the one it fixed: Tailwind scans SOURCE for literal class names, so
an interpolated `rounded-#{@Rounded}` is invisible to it and the CSS is
simply never generated. The scanner would have seen the bare string
`rounded-`, which is not a class at all.
The tokens map to written-out class names now, and the attr carries a
`values:` list so a typo fails at compile time instead of silently
rendering a square card.Third review round found the previous round's fixes had their own defects: - A single data point painted nothing again under the default (auto) domain. The auto x-domain collapses x_min == x_max, so every x mapped to 0 and the 'held value across the range' fix produced M0,y L0,y — a zero-length segment. Degenerate x-spans now widen to the full width. - Flooring a zero bar's height to a 1px hairline left its y at the baseline, so the bar spanned baseline..baseline+1 — one pixel below the viewBox, and clipped away. Negative bars had the same shape. y is derived from the floored height now. - A :label without a String.Chars impl (a tuple, a map) raised Protocol.UndefinedError mid-render and took the page down over a tooltip. - The y padding was an absolute 1.0e-9, below one ULP past ~1e7, so large flat series lost their padding and sank onto the axis. It is relative now. Tests assert the outcome rather than the markup shape: assert_visible_segment rejects a path whose endpoints coincide, and assert_bars_within_viewbox rejects a rect the browser would clip.
Fourth review round, against the third round's fixes. Rather than patch the symptoms again, this fixes the cause. A collapsed domain has no left or right, so it now scales to the CENTRE — the same treatment a flat y series already got — and a path that genuinely paints nothing renders a dot at the point's true position. That replaces the previous 'widen a zero-length path to full width' hack, which had two failure modes of its own: it painted a full-width line for a datum outside an explicit x_domain, asserting a reading across a range that was never measured, and it collapsed any two points sharing an x to the first y — silently dropping the second value while still looking plausible. Also fixed: - A small negative value among large positives puts the baseline on the bottom edge, so the 1px hairline hung from height..height+1 and was clipped away. Bars are clamped into the viewBox on both sides now. - The y padding was magnitude-relative, which swamped the range: 1.0e12 counters differing by 100 flattened onto one row of pixels. It is a fraction of the range whenever there is one, and falls back to magnitude only for a genuinely flat series. - label_text/1 rescued only Protocol.UndefinedError, but to_string/1 raises ArgumentError for a keyword list — List.Chars is implemented for lists, it just fails on non-iodata. It also guarded the tooltip but not the rendered label, so show_labels turned the same datum back into a page crash. - Values at the float ceiling overflowed while being padded, raising mid-render.
ddon pushed a commit
that referenced
this pull request
Jul 28, 2026
PR #668 (SMTP transport settings + optional queue hook): - Fix the dialyzer failure that broke `mix precommit` — the sender-address check had an unreachable catch-all clause, so `mix dialyzer` halted with exit 2 and the new `test.js` alias step never ran. - Make SMTP `username`/`password` optional. The PR added `auth: never` for "relays that authenticate by IP" and `security: none` for a plaintext internal relay, but marking the login required made both unreachable: the form refused to submit and `connected?/1` refused to call the connection configured, so mail silently fell back to the built-in mailer. The probe stops forcing `auth: always` when there is no login to prove. - Persist blanks on the personal integrations form the way the website form does (drop for `:password` only), so a cleared CA bundle or timeout no longer keeps its old value behind an empty field. - Read `:type` softly on both save paths, matching the hardening applied to `setup_field/1` — a provider field map without it rendered fine and then raised KeyError on submit. - Reject an SMTP timeout with a unit ("30s", "30 minutes") instead of silently using the number `Integer.parse/1` found. - Document the five new SMTP error reasons on `deliver_via_integration/3`, and warn on `maybe_enqueue/2` that `skip_queue: true` still re-runs `intercept_before_send/2`. PR #669 (Chart, StatusDot, ConnectAccountButton): - Skip `mix test.js` when the glob matches nothing — `node --test` with no file arguments walks the whole CWD, deps/ and _build/ included. Reviews: dev_docs/pull_requests/2026/{668-smtp-transport-settings-queue-hook, 669-chart-statusdot-connectbutton-components}/CLAUDE_REVIEW.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit
that referenced
this pull request
Jul 28, 2026
Independent re-check of release 1.7.217 (d7008e0) against each review's findings: every claimed fix confirmed present in the released code with file:line evidence, gates re-run (mix quality.ci exit 0, format clean, touched unit suites + test.js green). Records the CHANGELOG gap: the #667 auth fixes ship in 1.7.217 with no changelog entry.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds three server-rendered core components —
Chart,StatusDot,ConnectAccountButton— plus an optionalvalue_coloronstat_card. Allzero-JS except the popup hook, theme-aware via
currentColor, and LiveView-native(assigns change → SVG re-renders).
Extracted from NordSwitch, which runs hand-rolled versions today. Core had no
chart primitives at all, so every consumer either hand-rolled SVG or pulled in a
JS charting library.
Nothing in the ecosystem consumes these yet, which is why the API renames
below were free to make.
Components
line_chart/1— line/area over{x, y}points;stepfor interval data(right-open steps), optional
marker_x"now" line, auto or explicit domains,:emptyslot.sparkline/1— polyline over a list of numbers.bar_chart/1— vertical bars from a zero baseline, native<title>tooltips, optional per-datum
class, aligned labels.status_dot/1— semantic coloured dot + optional label + optionalpulse.Fills the gap between nothing and the domain-specific badges in
Badge;phoenix_kit_publishinghand-rolls exactly this today.connect_account_button/1— OAuth-popup account linking (distinct fromOAuthProvider, which is app sign-in), driven by a genericPopupLinkhook.What the review pass changed
The components were handed over deliberately untested. Four external review
rounds plus a test suite found, among others:
bar_chartscaled frommax(value), so a negative value produced anegative rect height — SVG discards those, so the bar silently vanished. An
all-negative series divided by an epsilon floor and threw the geometry to
astronomical coordinates.
nil, a string, or aDecimalraised anuncaught
ArithmeticErrormid-render — andDecimalis what Ecto hands you formoney, in a kit with a billing module.
connect_account_buttonused an inlineonclick— CSP-hostile in a kitthat removed inline handlers everywhere else — whose
return falsecancellednavigation unconditionally, including when the popup was blocked. That is
exactly when the documented "falls back to normal navigation" was meant to save
it. It is a hook now.
breaks
phx-hook.check kept
window.openerfrom third parties. The popup navigates on to theprovider and the opener reference survives. The doc now names the real
mitigation (COOP +
postMessage).role="img"with no accessible name;status_dotconveyedstate through colour alone.
Rounds 2, 3 and 4 each found defects in the previous round's fixes — a fix
aimed at a narrow symptom kept re-creating the same class of bug on a path it
did not touch. The round-4 resolution was to fix the cause instead: a collapsed
domain scales to the centre (the same treatment a flat y series already got),
and a path that genuinely paints nothing renders a dot at the point's true
position rather than a fabricated full-width line across a range nobody measured.
API changes, made while they were still free
area_chart→line_chart(area_chart area={false}was a line chart)label→aria_label, so it stops colliding with bar data'slabel:ConnectAccountPopup→PopupLink— nothing about it is OAuth-specific,and hook names freeze once hosts copy the bundle
Tests
test/phoenix_kit_web/components/core/{chart,status_dot,connect_account_button,stat_card}_test.exsplus
test/js/popup_link.test.cjs, a Node harness for the hook's decision logic(which clicks to intercept, where to place the popup, same-origin). Run via
mix test.js, wired intomix precommit, skips itself when node isn't installed.The chart assertions check the outcome, not the markup shape —
assert_visible_markrejects a path whose points coincide,
assert_bars_within_viewboxrejects a rectthe browser would clip. Both were written after shape-matching assertions passed
on charts that painted nothing.
Full suite: 2310 tests, 0 failures;
mix precommitclean.Notes and known limits:
dev_docs/2026-07-28-chart-statusdot-connectbutton-components.md.