Conversation
Moves banner-thermometer out of iaux-donation-form as ia-donation-thermometer, with a story and tests on Vitest. Only the petabox banner uses it so it gets its own directory rather than living under the donation form. The shared-resize-observer dep is gone in favor of a plain ResizeObserver, and the label is re-observed when it swaps sides (the old code stopped measuring it after the first swap). CSS variables move to --ia-donation-thermometer-*, the pill shape comes from a large border radius instead of measuring the host height, and the progress bar gets an accessible name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #97 +/- ##
==========================================
- Coverage 91.22% 91.17% -0.05%
==========================================
Files 60 61 +1
Lines 2518 2585 +67
Branches 582 601 +19
==========================================
+ Hits 2297 2357 +60
- Misses 77 78 +1
- Partials 144 150 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The near-goal message used a curly apostrophe and the reached message a straight one. Both are curly now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf
Private variables on :host inherit into whatever is nested or slotted, so the README asks for a component prefix. --fill-color-- and friends are now --donation-thermometer-fill-color-- and so on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf
nsharma123
left a comment
There was a problem hiding this comment.
Code-review pass on the migration. The move itself reads faithfully against the original banner-thermometer, and I verified the ResizeObserver claim holds: patching updated() back to the old "only re-observe when currentAmountMode changes" semantics makes keeps measuring the value label after it changes sides fail, so that regression test does what the description says.
No blockers. Everything below is behaviour carried over from the original component rather than something this PR broke — flagging it here because this PR is the accessibility/ResizeObserver pass, so it's the natural moment.
QA results (all steps pass) are on WEBDEV-9050. Locally: 643 tests pass, eslint 0 errors, prettier clean, new element at ~91% statements.
| return html` | ||
| <div | ||
| class="container" | ||
| role="progressbar" |
There was a problem hiding this comment.
role="progressbar" is on the wrong node (medium, a11y)
The role and the four aria-value* attributes sit on .container, which wraps both the bar and .donate-goal. So the goal text is a descendant of the progressbar — I confirmed this in the DOM on the review app.
Per the ARIA presentational-children rule, progressbar descendants aren't exposed to assistive tech, and aria-label overrides content for the accessible name either way. Net effect: $6.5MM GOAL never reaches a screen reader, and in message mode WE'VE ALMOST REACHED OUR GOAL! is lost entirely.
The ticket's own QA wording — "the bar inside the element's shadow root is role="progressbar"" — describes the intended structure better than the code does.
Fix is small: move role and the aria-* attributes down onto .thermometer-background (line 81), which is the actual bar and is already a sibling of .donate-goal rather than an ancestor.
There was a problem hiding this comment.
Fixed in fcd9b92. role and the four aria-value* attributes are on .thermometer-background now. The outer .container was only there to hold the role, so it is gone too.
New test keeps the goal text out of the progressbar asserts the progressbar is the background and that it does not contain .donate-goal (with an exist check on the goal so it cannot pass vacuously).
| aria-label=${this.label} | ||
| aria-valuemin="0" | ||
| aria-valuemax="${this.goalAmount}" | ||
| aria-valuenow="${this.currentAmount}" |
There was a problem hiding this comment.
aria-valuenow isn't clamped to aria-valuemax (low, a11y)
Reproduced on the review app at the QA step that sets Current amount to 7000000 against the 6500000 goal: the bar reports aria-valuenow="7000000" with aria-valuemax="6500000".
percentComplete clamps the visual fill to 100%, but the aria value is passed through raw, so an over-goal fundraiser reports out-of-range values. Worth clamping aria-valuenow to goalAmount here (aria-valuetext already carries the true unclamped amount for anyone who wants it).
There was a problem hiding this comment.
Fixed in fcd9b92, and aria-valuemax with it. Both go through getters now:
private get progressMax(): number {
return Number.isFinite(this.goalAmount) && this.goalAmount > 0
? this.goalAmount
: 0;
}
private get progressValue(): number {
if (!Number.isFinite(this.currentAmount)) return 0;
return Math.min(Math.max(this.currentAmount, 0), this.progressMax);
}So a negative or NaN goal reports aria-valuemax="0" rather than a range with min above max. aria-valuetext still carries the displayed amount.
Worth flagging that my first attempt named these ariaValueNow / ariaValueMax, which collide with ARIAMixin on HTMLElement (both are public string | null there). A private member of that name breaks the class's structural assignability to Element, which cascaded into 22 tsc errors including a TS1238 on @customElement that pointed nowhere near the cause. pnpm lint and pnpm test were both green through it, because neither typechecks. Separate ticket for a tsc --noEmit CI step, I think.
| updated(): void { | ||
| this.observeParts(); | ||
| } |
There was a problem hiding this comment.
Re-observing on every render raises a ResizeObserver loop error on each left→right label swap (medium)
Reproducible on the review app and locally. Every time the amount label moves from inside the fill to outside it, Chrome raises:
ResizeObserver loop completed with undelivered notifications.
Driving six amount changes on the review app gave exactly three errors — one per left→right transition, with the reverse direction clean. It also fires on the ~600px responsive step, so the ticket's "No console errors either way" QA line isn't quite accurate for this element.
Cause: the label is a different DOM node on each side, and the replacement gets newly observed from inside the observer's own delivery cycle. Moving it out of the fill also changes the fill's flex width, so there's more to deliver than one cycle allows.
This isn't introduced here — the old code hit it too — but calling observeParts() on every update makes it more frequent: across an identical four-swap sequence locally, old observe-once behaviour produced 2 errors and this produces 4.
Worth caring about because it surfaces as an uncaught window error rather than a console API call, so anything hooked to window.onerror in petabox will collect it on every banner resize.
Heads-up on the fix: I tried the obvious cheap one — guarding the fill so it isn't re-observed each render — and it does not help (still 4 errors, though all 23 tests stay green). Getting rid of this properly wants a single stable label node that keeps its DOM position and is placed on either side of the fill with CSS, instead of being re-rendered into a new slot. That would also stop the label perturbing the fill's width, which is a separate latent oddity: min-width: auto on the flex item lets a wide label stretch the fill past its intended percentage.
There was a problem hiding this comment.
Fixed in fcd9b92, and you were right that the cheap guard is not enough. The label is one node in one place in the DOM now, placed by offset from the end of the fill:
.thermometer-value { left: var(--fill-end--, 0%); width: max-content; }
.value-left .thermometer-value { transform: translateX(-100%); }Measured with an in-page window.addEventListener("error") (Playwright's pageerror never sees these, which is the same reason a console check misses them), over four left-to-right swaps plus a container-resize pass: 8 errors on c116430, 0 now. Side decisions are unchanged, L R L R L R L R on both.
Your point about the fill's width was the half I would have missed. The label is out of flow, so min-width: auto on the flex item cannot stretch the fill any more, and the fill lands on its exact percentage at every width I tried. I also dropped display: flex from .thermometer-background so that shrink path is gone rather than dormant.
| /** Observes the fill once and follows the value label as it moves. */ | ||
| private observeParts(): void { | ||
| const observer = this.resizeObserver; | ||
| if (!observer) return; | ||
|
|
||
| if (this.thermometerFill) observer.observe(this.thermometerFill); |
There was a problem hiding this comment.
Doc comment says "once", but the fill is re-observed on every render (nit)
Line 150 runs on each updated(), and per spec observe() on an already-observed target removes and re-adds it — which resets its last-reported size and costs an extra notification every render. The value label just below is guarded against exactly this (line 153); the fill isn't.
Cheap to guard the same way, which would also make the "Observes the fill once" comment true. To be clear though, I tested this in isolation and it does not fix the loop error above — see the comment on updated().
There was a problem hiding this comment.
Fixed in fcd9b92. Both targets go through one helper that leaves an unchanged target alone:
private observeInPlaceOf(
observer: ResizeObserver,
observed: Element | null,
current: Element | null,
): Element | null {
if (current === observed) return observed;
if (observed) observer.unobserve(observed);
if (current) observer.observe(current);
return current;
}The label still needs following, but only because currentAmountMode: off discards it, not because it changes sides. Comment says that now.
| } | ||
|
|
||
| private get percentComplete(): number { | ||
| return Math.min((this.currentAmount / this.goalAmount) * 100, 100); |
There was a problem hiding this comment.
goalAmount = 0 renders style="width: NaN%" (low, robustness)
Both of these are reachable straight from the demo's number inputs:
goalAmount = 0, currentAmount = 0→0/0→NaN→ the element emits the literal attributestyle="width: NaN%"(verified in the DOM).- A negative
currentAmount→ a negative percentage.
In both cases the browser drops the invalid width, so the fill falls back to content-based flex sizing — I measured ~42px and ~91px respectively — instead of collapsing to zero. So it renders a visible, wrong-looking fill rather than an empty bar.
Clamping to [0, 100] and guarding the zero goal covers both, e.g.:
private get percentComplete(): number {
if (!(this.goalAmount > 0)) return 0;
const pct = (this.currentAmount / this.goalAmount) * 100;
return Math.min(Math.max(pct, 0), 100);
}There was a problem hiding this comment.
Fixed in fcd9b92, close to your version:
private get percentComplete(): number {
const goal = this.progressMax;
if (goal === 0 || !Number.isFinite(this.currentAmount)) return 0;
const percent = (this.currentAmount / goal) * 100;
return Math.min(Math.max(percent, 0), 100);
}Guarding the amount as well as the goal, since Math.min(Math.max(NaN, 0), 100) is still NaN. formatNumber returns $0 for a non-finite input too, so the label cannot read $NaNMM.
The fill's width comes from a --fill-end-- custom property now, and it is written var(--fill-end--, 0%). Without the fallback a missing property makes the declaration invalid, width falls back to auto, and the bar paints as a fully funded fundraiser. Tests for a zero goal, a negative amount, a NaN amount and a NaN goal.
| .thermometer-background { | ||
| background-color: var(--donation-thermometer-track-color--); | ||
| padding: 0; | ||
| height: 100%; | ||
| border-radius: var(--donation-thermometer-border-radius--); | ||
| border: var(--donation-thermometer-border--); | ||
| overflow: hidden; | ||
| display: flex; | ||
| align-items: center; | ||
| } |
There was a problem hiding this comment.
Bar paints 2px taller than the declared height (low, CSS)
This rule is content-box with a 1px border on line 283 and height: 100% on line 281, so the bar renders height + 2px and overflows the host box. Measured on the review app: 22px against the 20px default, and 42px at the QA step's 40px.
Invisible in petabox (which sets the border to 0) and easy to miss behind the pill radius, but it shows up with the Square preset or any non-zero border — and it matters a little more now that :host declares an explicit height, since the painted bar no longer matches it.
box-sizing: border-box on this rule fixes it.
There was a problem hiding this comment.
Fixed in fcd9b92. box-sizing: border-box on the rule. Measured 22px to 20px at the default and 42px to 40px at the 40px step.
New test paints the bar at its declared height pins it at 40px, which fails at 42px if the rule goes away.
| `, | ||
| ]; | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing HTMLElementTagNameMap declaration (nit)
The README's "Adding a Component → Naming" section asks for this explicitly ("Declare each element in HTMLElementTagNameMap so querySelector is typed and a mistyped tag in a template is caught at build time"):
declare global {
interface HTMLElementTagNameMap {
'ia-donation-thermometer': IADonationThermometer;
}
}Fair warning that it's a weakly-adopted rule — only ia-item-navigator and ia-status-indicator currently comply — so entirely reasonable to skip. But it's free, it'd drop the cast in ia-donation-thermometer.test.ts, and ia-status-indicator puts it at the end of the file like this if you want a template.
There was a problem hiding this comment.
Skipping this one. Nothing here needs it: no createElement("ia-donation-thermometer"), and no untyped querySelector being assigned to the element type. Where a call site genuinely wants the type I would rather say so there, with querySelector<IADonationThermometer>(...), than declare it globally.
Fair point that it would drop the cast in the test, but one cast is cheaper than the boilerplate on every element.
Your 2-of-17 count is the real signal though. Either the README line should go or all 17 elements should comply, and picking that off in a migration PR just widens the split. Happy to file it as its own ticket if you think it should be the former.
The value label is one node placed by offset from the end of the fill now, instead of being re-rendered into a different parent each time it changes sides. That was raising "ResizeObserver loop completed with undelivered notifications" on every left-to-right swap, and it lands as an uncaught window error rather than a console call, so petabox's error handler would collect it on every banner resize. Four swaps went from 8 errors to none. Only the fill is clipped now, so the label isn't cut off when it sits past the end of the track. role="progressbar" and the aria-value* attributes are on the bar itself rather than a wrapper that also held the goal text, which was keeping the goal out of the accessibility tree. aria-valuenow and aria-valuemax are clamped to a valid range, and an amount or goal that isn't a positive finite number empties the bar instead of emitting width: NaN%. box-sizing on the bar makes it paint at the height it declares, 2px shorter than before. The two new getters are named clear of ARIAMixin, which already declares ariaValueNow and ariaValueMax on every element. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpyQxVSkZFrWxxGamCcNhY
|
Thanks Neeraj, all of it was worth picking up. Six fixed in fcd9b92, one skipped, details in the inline replies. The resize loop went 8 errors to 0 over four label swaps. Your read on the cause was right and the cheap guard really does not help: the label is one node placed by offset now, and out of flow so it cannot stretch the fill. Two things beyond your list: Narrow tracks still have a problem, and I am leaving it. Clipping now covers only the fill, so the label is never cut off. But below about 240px the label runs into the goal text instead (31px of overlap at 200px, 71px at 120px). Nothing to do with the banner at real widths, and there is no width where
Below ~200px CI does not typecheck. My first cut named the new getters Still open: the clip layer takes the full border radius rather than the border's inner curve, so at an intermediate radius like 4px the fill can paint about 1px over the corner. Invisible at both shipped presets (9999px and 0). Left it. 654 tests pass, tsc clean, build clean, eslint 0 errors. |
…onation-thermometer * origin/main: 1.1.1 (#118) WEBDEV-9132: Fix Safari image-viewer wide-layout breakpoint (#115) 1.1.0 (#116) WEBDEV-9129: Exclude story files from coverage reporting (#113) WEBDEV-9121: Migrate histogram-date-range into elements (#112) WEBDEV-9130: Exclude nested dist and worktree tests from local runs (#114) v1.0.0 (#111) WEBDEV-9025: Add a CLAUDE.md covering the publish flow (#94) WEBDEV-9063: Typecheck in CI (#107) v0.4.1 (#108) WEBDEV-9066: Guard custom element registration against duplicate defines (#106)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RS8ThjiMNiq7HBBAGash1
WEBDEV-9050. First of the five in WEBDEV-8602, the donation form migration. Based on main, not stacked. Only the petabox banner uses the thermometer, so it gets its own directory rather than living under the form.
donation-banner-thermometermoves over asia-donation-thermometer, with a story and the tests on Vitest.What changed beyond the move:
ResizeObserverfor the fill and the value label. The old code only observed the label whencurrentAmountModechanged, and the label re-rendered in a new spot when it swapped sides of the fill, so after the first swap it was never measured again. Both targets are observed once and followed if Lit replaces them, with a test that fails on the old behavior.ResizeObserver loop completed with undelivered notificationson every left-to-right swap (8 errors over four swaps, none now). It lands as an uncaught window error rather than a console call, so petabox's handler would have collected it on every banner resize. Being out of flow, the label also can't stretch the fill past its percentage.role="progressbar"and thearia-value*attributes sit on.thermometer-backgroundinstead of a wrapper that also held the goal text, which was keeping$6.5MM GOALout of the accessibility tree.aria-valuenowandaria-valuemaxare clamped to a valid range.width: NaN%. A zero, negative or non-finite amount or goal reads as no progress, and the fill's width carries a0%fallback so a missing custom property can't paint a fully funded fundraiser.--bannerThermometerHeightare gone. Only the fill is clipped, so the label isn't cut off when it sits past the end of the track, andbox-sizingmakes the bar paint at the height it declares (it was 2px taller).--ia-donation-thermometer-*(height, fill/track colors, border, radius, value colors, goal padding). The goal text color follows--ia-theme-primary-text-color. petabox sets three of the old names and gets updated when it switches over.label, default "Donation progress"), and the default strings go throughmsg().Known, not fixed here: below about 240px the value label runs into the goal text.
mainhandled that width by pinning the track at 84px and painting a 0px fill on a half-funded bar, so there's no width wheremainwas right and this isn't. The proper fix is a container query that drops the goal text under a threshold, filed separately.Verified locally: tsc clean, build, full suite 654 passing, eslint 0 errors and prettier clean.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JpyQxVSkZFrWxxGamCcNhY