@repobit/dex-system-design 0.23.12 → 0.23.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.23.13](https://github.com/bitdefender/dex-core/compare/@repobit/dex-system-design@0.23.12...@repobit/dex-system-design@0.23.13) (2026-04-03)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **DEX-1014:** fix for barchart alignment
11
+
12
+
6
13
  ## [0.23.12](https://github.com/bitdefender/dex-core/compare/@repobit/dex-system-design@0.23.11...@repobit/dex-system-design@0.23.12) (2026-04-03)
7
14
 
8
15
  **Note:** Version bump only for package @repobit/dex-system-design
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repobit/dex-system-design",
3
- "version": "0.23.12",
3
+ "version": "0.23.13",
4
4
  "description": "Design system based on Web Components.",
5
5
  "author": "Iordache Matei Cezar <miordache@bitdefender.com>",
6
6
  "homepage": "https://github.com/bitdefender/dex-core#readme",
@@ -89,5 +89,5 @@
89
89
  "volta": {
90
90
  "node": "24.14.0"
91
91
  },
92
- "gitHead": "86f179bab0198a14589c7297b74c8c045120c13d"
92
+ "gitHead": "d009cffbbe3f294b3b3efea4834b9b25d4e7baa1"
93
93
  }
@@ -2,22 +2,17 @@ import { css } from "lit";
2
2
 
3
3
  export default css`
4
4
 
5
- /* ──────────────────────────────────────────────────────────────
6
- Compare layout — uses design-system tokens (src/tokens/tokens.js).
7
- --cs-* are local aliases where themes override bar colors.
8
- ──────────────────────────────────────────────────────────────── */
9
-
10
5
  :host(bd-compare-section) {
11
6
  display: block;
12
7
 
13
- --cs-bar-primary-fg: var(--color-neutral-0);
14
- --cs-bar-primary-bg: var(--color-blue-500);
15
- --cs-bar-secondary-bg: var(--color-neutral-100);
16
- --cs-bar-track-bg: transparent;
17
- --cs-bar-track-border: var(--color-neutral-200);
18
- --cs-bar-height: var(--dimension-48px);
19
- --cs-gap: var(--spacing-32);
20
- --cs-grid-max: 1228px;
8
+ --cs-bar-primary-fg: var(--color-neutral-0);
9
+ --cs-bar-primary-bg: var(--color-blue-500);
10
+ --cs-bar-secondary-bg: var(--color-neutral-100);
11
+ --cs-bar-track-bg: transparent;
12
+ --cs-bar-track-border: var(--color-neutral-200);
13
+ --cs-bar-height: var(--dimension-48px);
14
+ --cs-gap: var(--spacing-32);
15
+ --cs-grid-max: 1228px;
21
16
  }
22
17
 
23
18
  :host(compare-card) {
@@ -30,11 +25,6 @@ export default css`
30
25
  width: 100%;
31
26
  }
32
27
 
33
-
34
- /* ──────────────────────────────────────────────────────────────
35
- 2. compare-section
36
- ──────────────────────────────────────────────────────────────── */
37
-
38
28
  .cs-sr-only {
39
29
  position: absolute;
40
30
  width: 1px;
@@ -59,13 +49,8 @@ export default css`
59
49
  margin: 0 auto var(--spacing-40);
60
50
  }
61
51
 
62
- .cs-title {
63
- margin: 0 0 var(--spacing-8);
64
- }
65
-
66
- .cs-subtitle {
67
- margin: 0;
68
- }
52
+ .cs-title { margin: 0 0 var(--spacing-8); }
53
+ .cs-subtitle { margin: 0; }
69
54
 
70
55
  .cs-grid {
71
56
  display: grid;
@@ -83,20 +68,16 @@ export default css`
83
68
  }
84
69
  }
85
70
 
86
-
87
- /* ──────────────────────────────────────────────────────────────
88
- 3. compare-card
89
- ──────────────────────────────────────────────────────────────── */
90
-
91
71
  .card {
92
- background: var(--color-neutral-25);
93
- border-radius: var(--radius-3xl);
94
- padding: var(--spacing-32);
95
- display: flex;
96
- flex-direction: column;
97
- gap: var(--spacing-24);
98
- box-sizing: border-box;
99
- height: 100%;
72
+ background: var(--color-neutral-25);
73
+ border-radius: var(--radius-3xl);
74
+ padding: var(--spacing-32);
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: var(--spacing-24);
78
+ box-sizing: border-box;
79
+ height: 100%;
80
+ justify-content: flex-start;
100
81
  }
101
82
 
102
83
  .card-header {
@@ -104,13 +85,13 @@ export default css`
104
85
  flex-direction: column;
105
86
  align-items: flex-start;
106
87
  gap: var(--spacing-8);
107
- flex: 1 1 auto;
88
+ flex: 0 0 auto;
108
89
  }
109
90
 
110
91
  .card-icon-wrap {
111
92
  flex-shrink: 0;
112
- width: var(--icon-2xl-size);
113
- height: var(--icon-2xl-size);
93
+ width: var(--icon-2xl-size, 48px);
94
+ height: var(--icon-2xl-size, 48px);
114
95
  }
115
96
 
116
97
  .card-icon-wrap bd-img {
@@ -126,13 +107,8 @@ export default css`
126
107
  align-self: stretch;
127
108
  }
128
109
 
129
- .card-title {
130
- margin: 0;
131
- }
132
-
133
- .card-description {
134
- margin: 0;
135
- }
110
+ .card-title { margin: 0; }
111
+ .card-description { margin: 0; }
136
112
 
137
113
  .card-bars {
138
114
  display: flex;
@@ -148,11 +124,6 @@ export default css`
148
124
  flex: 0 0 auto;
149
125
  }
150
126
 
151
-
152
- /* ──────────────────────────────────────────────────────────────
153
- 4. compare-bar
154
- ──────────────────────────────────────────────────────────────── */
155
-
156
127
  .bar-track {
157
128
  position: relative;
158
129
  height: var(--cs-bar-height);
@@ -180,14 +151,10 @@ export default css`
180
151
  min-width: 0;
181
152
  }
182
153
 
183
- :host(compare-bar.compare-no-motion) .bar-fill {
184
- transition: none;
185
- }
154
+ :host(compare-bar.compare-no-motion) .bar-fill { transition: none; }
186
155
 
187
156
  @media (prefers-reduced-motion: reduce) {
188
- .bar-fill {
189
- transition: none;
190
- }
157
+ .bar-fill { transition: none; }
191
158
  }
192
159
 
193
160
  .bar-label {
@@ -198,9 +165,7 @@ export default css`
198
165
  white-space: nowrap;
199
166
  }
200
167
 
201
- .bar-score {
202
- flex: 0 0 auto;
203
- }
168
+ .bar-score { flex: 0 0 auto; }
204
169
 
205
170
  .bar-label,
206
171
  .bar-score {
@@ -210,11 +175,8 @@ export default css`
210
175
  letter-spacing: var(--typography-body-regular-letterSpacing);
211
176
  }
212
177
 
213
- .bar-score {
214
- font-variant-numeric: tabular-nums;
215
- }
178
+ .bar-score { font-variant-numeric: tabular-nums; }
216
179
 
217
- /* ── primary (Bitdefender) ── */
218
180
  :host(compare-bar[variant="primary"]) .bar-fill {
219
181
  background: var(--cs-bar-primary-bg);
220
182
  }
@@ -224,7 +186,6 @@ export default css`
224
186
  font-weight: var(--typography-fontWeight-semibold);
225
187
  }
226
188
 
227
- /* ── secondary ── */
228
189
  :host(compare-bar[variant="secondary"]) .bar-fill,
229
190
  :host(compare-bar:not([variant])) .bar-fill {
230
191
  background: var(--cs-bar-secondary-bg);
@@ -237,32 +198,24 @@ export default css`
237
198
  font-weight: var(--typography-body-regular-fontWeight);
238
199
  }
239
200
 
240
-
241
- /* ══════════════════════════════════════════════════════════════
242
- 5. THEME OVERRIDES (DS palette)
243
- ══════════════════════════════════════════════════════════════ */
244
-
245
201
  :host(bd-compare-section.theme-green) {
246
202
  --cs-bar-primary-bg: var(--color-green-500);
247
203
  --cs-bar-primary-fg: var(--color-neutral-0);
248
204
  --cs-icon-color: var(--color-green-500);
249
205
  --cs-icon-filter: invert(35%) sepia(72%) saturate(500%) hue-rotate(100deg) brightness(95%) contrast(95%);
250
206
  }
251
-
252
207
  :host(bd-compare-section.theme-red) {
253
208
  --cs-bar-primary-bg: var(--color-red-500);
254
209
  --cs-bar-primary-fg: var(--color-neutral-0);
255
210
  --cs-icon-color: var(--color-red-500);
256
211
  --cs-icon-filter: invert(22%) sepia(90%) saturate(700%) hue-rotate(345deg) brightness(95%) contrast(95%);
257
212
  }
258
-
259
213
  :host(bd-compare-section.theme-purple) {
260
214
  --cs-bar-primary-bg: #7c3aed;
261
215
  --cs-bar-primary-fg: var(--color-neutral-0);
262
216
  --cs-icon-color: #7c3aed;
263
217
  --cs-icon-filter: invert(25%) sepia(80%) saturate(800%) hue-rotate(255deg) brightness(90%) contrast(95%);
264
218
  }
265
-
266
219
  :host(bd-compare-section.theme-dark) {
267
220
  --cs-bar-primary-bg: var(--color-blue-400);
268
221
  --cs-bar-primary-fg: var(--color-neutral-0);
@@ -270,17 +223,6 @@ export default css`
270
223
  --cs-icon-filter: invert(65%) sepia(50%) saturate(500%) hue-rotate(195deg) brightness(105%) contrast(95%);
271
224
  }
272
225
 
273
-
274
- /* ══════════════════════════════════════════════════════════════
275
- 6. BAR SIZE VARIANTS
276
- ══════════════════════════════════════════════════════════════ */
277
-
278
- :host(bd-compare-section.bars-tall) {
279
- --cs-bar-height: var(--dimension-55px);
280
- }
281
-
282
- :host(bd-compare-section.bars-compact) {
283
- --cs-bar-height: var(--dimension-40px);
284
- --cs-gap: var(--spacing-24);
285
- }
226
+ :host(bd-compare-section.bars-tall) { --cs-bar-height: var(--dimension-55px); }
227
+ :host(bd-compare-section.bars-compact) { --cs-bar-height: var(--dimension-40px); --cs-gap: var(--spacing-24); }
286
228
  `;
@@ -1,18 +1,6 @@
1
1
  import { LitElement, html, nothing } from "lit";
2
2
  import { tokens } from "../../tokens/tokens.js";
3
3
  import compareCSS from "./compare.css.js";
4
- // ═══════════════════════════════════════════════════════════════
5
- // compare-bar
6
- // Attributes / Properties:
7
- // label {String} – Brand or product name
8
- // score {Number} – Numeric score
9
- // max-score {Number} – Max possible score (default: 6)
10
- // variant {String} – "primary" | "secondary"
11
- // score-label {String} – Optional override text for the score
12
- // reference-score {Number} – Baseline score (e.g. Bitdefender). With stretch
13
- // (set by compare-card), width maps [r_min,1] → [fill_min,100%].
14
- // scale {Number} – Only used when reference-score is unset (× max-score %)
15
- // ═══════════════════════════════════════════════════════════════
16
4
 
17
5
  function readStretchVars(el) {
18
6
  const styles = getComputedStyle(el);
@@ -24,32 +12,25 @@ function readStretchVars(el) {
24
12
  };
25
13
  }
26
14
 
27
- const SCORE_ANIM_MS = 800;
15
+ const SCORE_ANIM_MS = 800;
28
16
  const SCORE_ANIM_EASING = (t) => 1 - (1 - t) ** 3;
29
17
 
30
- /** After first paint of this document, intro is “consumed” (scroll-back won’t replay). */
31
- let compareIntroDone = false;
32
- /** @type {ReturnType<typeof setTimeout> | 0} */
18
+ let compareIntroDone = false;
33
19
  let compareIntroTimer = 0;
34
20
 
35
- function shouldPlayCompareIntro() {
36
- return !compareIntroDone;
37
- }
21
+ function shouldPlayCompareIntro() { return !compareIntroDone; }
38
22
 
39
23
  function scheduleCompareIntroDone() {
40
24
  if (compareIntroDone || compareIntroTimer) return;
41
25
  compareIntroTimer = setTimeout(() => {
42
26
  compareIntroTimer = 0;
43
- compareIntroDone = true;
27
+ compareIntroDone = true;
44
28
  }, SCORE_ANIM_MS + 120);
45
29
  }
46
30
 
47
31
  function prefersReducedMotion() {
48
- try {
49
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
50
- } catch {
51
- return false;
52
- }
32
+ try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; }
33
+ catch { return false; }
53
34
  }
54
35
 
55
36
  if (typeof globalThis !== "undefined" && prefersReducedMotion()) {
@@ -72,16 +53,15 @@ class CompareBar extends LitElement {
72
53
 
73
54
  constructor() {
74
55
  super();
75
- this.label = "";
76
- this.score = 0;
77
- this.maxScore = 6;
78
- this.variant = "secondary";
79
- this.scoreLabel = "";
56
+ this.label = "";
57
+ this.score = 0;
58
+ this.maxScore = 6;
59
+ this.variant = "secondary";
60
+ this.scoreLabel = "";
80
61
  this.referenceScore = 0;
81
- this.scale = 1;
62
+ this.scale = 1;
82
63
  this._animatedScore = NaN;
83
- /** @type {number} */
84
- this._scoreAnimRaf = 0;
64
+ this._scoreAnimRaf = 0;
85
65
  }
86
66
 
87
67
  connectedCallback() {
@@ -105,15 +85,7 @@ class CompareBar extends LitElement {
105
85
  updated(changed) {
106
86
  super.updated(changed);
107
87
  this.setAttribute("aria-label", this._barAriaLabel());
108
- const deps = [
109
- "score",
110
- "referenceScore",
111
- "scoreLabel",
112
- "variant",
113
- "maxScore",
114
- "label"
115
-
116
- ];
88
+ const deps = ["score", "referenceScore", "scoreLabel", "variant", "maxScore", "label"];
117
89
  if (!deps.some((k) => changed.has(k))) return;
118
90
 
119
91
  this._cancelScoreAnim();
@@ -132,15 +104,14 @@ class CompareBar extends LitElement {
132
104
 
133
105
  _shouldAnimateCompetitorScore() {
134
106
  if (this.variant === "primary" || this.scoreLabel) return false;
135
- const t = parseFloat(this.score);
136
- return Number.isFinite(t);
107
+ return Number.isFinite(parseFloat(this.score));
137
108
  }
138
109
 
139
110
  _scoreAnimStart() {
140
111
  const target = parseFloat(this.score);
141
- const ref = parseFloat(this.referenceScore);
142
- const maxM = parseFloat(this.maxScore) || 6;
143
- let start = ref > 0 ? ref : maxM;
112
+ const ref = parseFloat(this.referenceScore);
113
+ const maxM = parseFloat(this.maxScore) || 6;
114
+ let start = ref > 0 ? ref : maxM;
144
115
  if (start <= target) start = Math.min(maxM, target + 0.2);
145
116
  return start;
146
117
  }
@@ -154,26 +125,22 @@ class CompareBar extends LitElement {
154
125
 
155
126
  _runScoreAnim() {
156
127
  const target = parseFloat(this.score);
157
- const start = this._animatedScore;
128
+ const start = this._animatedScore;
158
129
  if (!Number.isFinite(target) || !Number.isFinite(start)) return;
159
130
 
160
131
  const t0 = performance.now();
161
-
162
132
  const tick = (now) => {
163
- const elapsed = now - t0;
164
- const u = Math.min(1, elapsed / SCORE_ANIM_MS);
133
+ const u = Math.min(1, (now - t0) / SCORE_ANIM_MS);
165
134
  const e = SCORE_ANIM_EASING(u);
166
- const v = start + (target - start) * e;
167
- this._animatedScore = v;
135
+ this._animatedScore = start + (target - start) * e;
168
136
  if (u < 1) {
169
137
  this._scoreAnimRaf = requestAnimationFrame(tick);
170
138
  } else {
171
- this._scoreAnimRaf = 0;
139
+ this._scoreAnimRaf = 0;
172
140
  this._animatedScore = NaN;
173
141
  this.requestUpdate();
174
142
  }
175
143
  };
176
-
177
144
  this._scoreAnimRaf = requestAnimationFrame(tick);
178
145
  }
179
146
 
@@ -183,7 +150,7 @@ class CompareBar extends LitElement {
183
150
 
184
151
  const ref = parseFloat(this.referenceScore);
185
152
  if (ref > 0) {
186
- const r = Math.min(1, Math.max(0, s / ref));
153
+ const r = Math.min(1, Math.max(0, s / ref));
187
154
  const linearPct = r * 100;
188
155
  const { stretch, rMinStr, fillMin, gamma } = readStretchVars(this);
189
156
 
@@ -196,12 +163,12 @@ class CompareBar extends LitElement {
196
163
  return Math.min(100, Math.max(0, linearPct));
197
164
  }
198
165
 
199
- const denom = 1 - rMin;
200
- const wMin = Number.isFinite(fillMin) ? fillMin : 50;
201
- const g = Number.isFinite(gamma) && gamma > 0 ? gamma : 1;
202
- const u = (r - rMin) / denom;
203
- const t = Math.min(1, Math.max(0, u));
204
- const shaped = Math.pow(t, g);
166
+ const denom = 1 - rMin;
167
+ const wMin = Number.isFinite(fillMin) ? fillMin : 50;
168
+ const g = Number.isFinite(gamma) && gamma > 0 ? gamma : 1;
169
+ const u = (r - rMin) / denom;
170
+ const t = Math.min(1, Math.max(0, u));
171
+ const shaped = Math.pow(t, g);
205
172
  const stretched = wMin + shaped * (100 - wMin);
206
173
  return Math.min(100, Math.max(0, stretched));
207
174
  }
@@ -212,26 +179,20 @@ class CompareBar extends LitElement {
212
179
  return rawPct * (this.scale || 1);
213
180
  }
214
181
 
215
- get _displayScore() {
216
- return this.scoreLabel || this.score;
217
- }
182
+ get _displayScore() { return this.scoreLabel || this.score; }
218
183
 
219
184
  _renderScoreText() {
220
- if (this.variant === "primary" || this.scoreLabel) {
221
- return this._displayScore;
222
- }
185
+ if (this.variant === "primary" || this.scoreLabel) return this._displayScore;
223
186
  if (Number.isFinite(this._animatedScore)) {
224
187
  return CompareBar._formatScoreTick(this._animatedScore);
225
188
  }
226
189
  return this._displayScore;
227
190
  }
228
191
 
229
- /** @param {number} v */
230
192
  static _formatScoreTick(v) {
231
193
  return (Math.round(v * 100) / 100).toFixed(2);
232
194
  }
233
195
 
234
- /** Accessible name uses final numeric score (not the intro animation value). */
235
196
  _barAriaLabel() {
236
197
  const max = this.maxScore ?? 6;
237
198
  const val = this.scoreLabel || this.score;
@@ -252,9 +213,6 @@ class CompareBar extends LitElement {
252
213
 
253
214
  customElements.define("compare-bar", CompareBar);
254
215
 
255
- // ═══════════════════════════════════════════════════════════════
256
- // compare-card
257
- // ═══════════════════════════════════════════════════════════════
258
216
 
259
217
  class CompareCard extends LitElement {
260
218
  static _idSeq = 0;
@@ -265,35 +223,28 @@ class CompareCard extends LitElement {
265
223
  footnote : { type: String },
266
224
  footnoteHref : { type: String, attribute: "footnote-href" },
267
225
  iconSrc : { type: String, attribute: "icon-src" },
268
- /** When true, competitor bar widths use stretched mapping so small score gaps read clearly. */
269
226
  barStretch : { type: Boolean, attribute: "bar-stretch" },
270
- /** Visual floor % for the weakest competitor (rest of span goes to 100% / Bitdefender). */
271
- barFillMin : { type: Number, attribute: "bar-fill-min" },
272
- /**
273
- * Curve on normalized rank within [r_min, 1]. >1 pulls stronger competitors down vs Bitdefender
274
- * (bigger BD gap); =1 is pure linear spread.
275
- */
276
- barStretchGamma: { type: Number, attribute: "bar-stretch-gamma" },
277
- /** Shown only in the screen-reader chart hint (scores are “out of” this scale). */
278
- chartScale : { type: Number, attribute: "chart-scale" }
227
+ barFillMin : { type: Number, attribute: "bar-fill-min" },
228
+ barStretchGamma: { type: Number, attribute: "bar-stretch-gamma" },
229
+ chartScale : { type: Number, attribute: "chart-scale" }
279
230
  };
280
231
 
281
232
  static styles = [tokens, compareCSS];
282
233
 
283
234
  constructor() {
284
235
  super();
285
- const n = ++CompareCard._idSeq;
286
- this._headingId = `compare-card-title-${n}`;
287
- this._chartHelpId = `compare-card-chart-${n}`;
288
- this.title = "";
289
- this.description = "";
290
- this.footnote = "";
291
- this.footnoteHref = "";
292
- this.iconSrc = "";
293
- this.barStretch = true;
294
- this.barFillMin = 50;
236
+ const n = ++CompareCard._idSeq;
237
+ this._headingId = `compare-card-title-${n}`;
238
+ this._chartHelpId = `compare-card-chart-${n}`;
239
+ this.title = "";
240
+ this.description = "";
241
+ this.footnote = "";
242
+ this.footnoteHref = "";
243
+ this.iconSrc = "";
244
+ this.barStretch = true;
245
+ this.barFillMin = 50;
295
246
  this.barStretchGamma = 1;
296
- this.chartScale = 6;
247
+ this.chartScale = 6;
297
248
  }
298
249
 
299
250
  firstUpdated() {
@@ -316,10 +267,9 @@ class CompareCard extends LitElement {
316
267
  if (!slot) return;
317
268
 
318
269
  const apply = () => {
319
- const nodes = slot.assignedElements({ flatten: true });
320
- const bars = nodes.filter(
321
- (el) => el.localName === "compare-bar"
322
- );
270
+ const bars = slot
271
+ .assignedElements({ flatten: true })
272
+ .filter((el) => el.localName === "compare-bar");
323
273
 
324
274
  if (!this.barStretch) {
325
275
  this.style.setProperty("--cs-bar-stretch", "0");
@@ -328,7 +278,7 @@ class CompareCard extends LitElement {
328
278
  }
329
279
 
330
280
  const primary = bars.find((b) => b.variant === "primary");
331
- const ref = primary ? parseFloat(primary.score) : 0;
281
+ const ref = primary ? parseFloat(primary.score) : 0;
332
282
 
333
283
  if (!ref || ref <= 0) {
334
284
  this.style.setProperty("--cs-bar-stretch", "0");
@@ -356,10 +306,7 @@ class CompareCard extends LitElement {
356
306
  this.style.setProperty("--cs-bar-stretch", "1");
357
307
  this.style.setProperty("--cs-stretch-r-min", String(rMin));
358
308
  this.style.setProperty("--cs-bar-fill-min", String(this.barFillMin));
359
- this.style.setProperty(
360
- "--cs-bar-stretch-gamma",
361
- String(this.barStretchGamma)
362
- );
309
+ this.style.setProperty("--cs-bar-stretch-gamma", String(this.barStretchGamma));
363
310
  }
364
311
 
365
312
  this._refreshBars(bars);
@@ -374,6 +321,20 @@ class CompareCard extends LitElement {
374
321
  });
375
322
  }
376
323
 
324
+ /**
325
+ * Called by CompareSection to set a fixed min-height on .card-header
326
+ * so all headers in the same row are the same height.
327
+ */
328
+ setHeaderMinHeight(px) {
329
+ const header = this.shadowRoot?.querySelector(".card-header");
330
+ if (header) header.style.minHeight = px != null ? `${px}px` : "";
331
+ }
332
+
333
+ getHeaderHeight() {
334
+ const header = this.shadowRoot?.querySelector(".card-header");
335
+ return header ? header.getBoundingClientRect().height : 0;
336
+ }
337
+
377
338
  _renderFootnote() {
378
339
  if (!this.footnote) return "";
379
340
 
@@ -381,8 +342,8 @@ class CompareCard extends LitElement {
381
342
  return html`<bd-p kind="xsmall" class="card-footnote">${this.footnote}</bd-p>`;
382
343
  }
383
344
 
384
- const splitOn = "Source ";
385
- const idx = this.footnote.indexOf(splitOn);
345
+ const splitOn = "Source ";
346
+ const idx = this.footnote.indexOf(splitOn);
386
347
 
387
348
  if (idx === -1) {
388
349
  return html`
@@ -394,7 +355,7 @@ class CompareCard extends LitElement {
394
355
  `;
395
356
  }
396
357
 
397
- const before = this.footnote.slice(0, idx + splitOn.length);
358
+ const before = this.footnote.slice(0, idx + splitOn.length);
398
359
  const linkedText = this.footnote.slice(idx + splitOn.length);
399
360
 
400
361
  return html`
@@ -417,34 +378,34 @@ class CompareCard extends LitElement {
417
378
  <p class="cs-sr-only" id="${this._chartHelpId}">
418
379
  Bar lengths are scaled for visibility between brands. Use the announced scores for exact values; scale is out of ${this.chartScale}.
419
380
  </p>
381
+
420
382
  <div class="card-header">
421
383
  ${this.iconSrc
422
384
  ? html`
423
- <div class="card-icon-wrap" aria-hidden="true">
424
- <bd-img
425
- src="${this.iconSrc}"
426
- alt=""
427
- width="48"
428
- height="48"
429
- fit="contain"
430
- radius="none"
431
- shadow="none"
432
- loading="eager"
433
- ></bd-img>
434
- </div>
435
- `
385
+ <div class="card-icon-wrap" aria-hidden="true">
386
+ <bd-img
387
+ src="${this.iconSrc}"
388
+ alt=""
389
+ width="48"
390
+ height="48"
391
+ fit="contain"
392
+ radius="none"
393
+ shadow="none"
394
+ loading="eager"
395
+ ></bd-img>
396
+ </div>
397
+ `
436
398
  : ""}
437
-
438
399
  <div class="card-text-wrap">
439
400
  ${isMobile
440
401
  ? html`<bd-h as="h5" class="card-title" heading-id="${this._headingId}">${this.title}</bd-h>`
441
402
  : html`<bd-h as="h4" class="card-title" heading-id="${this._headingId}">${this.title}</bd-h>`}
442
403
  ${this.description
443
404
  ? html`
444
- <bd-p kind="${isMobile ? "small" : "regular"}" class="card-description">
445
- ${this.description}
446
- </bd-p>
447
- `
405
+ <bd-p kind="${isMobile ? "small" : "regular"}" class="card-description">
406
+ ${this.description}
407
+ </bd-p>
408
+ `
448
409
  : ""}
449
410
  </div>
450
411
  </div>
@@ -461,9 +422,6 @@ class CompareCard extends LitElement {
461
422
 
462
423
  customElements.define("compare-card", CompareCard);
463
424
 
464
- // ═══════════════════════════════════════════════════════════════
465
- // compare-section
466
- // ═══════════════════════════════════════════════════════════════
467
425
 
468
426
  class CompareSection extends LitElement {
469
427
  static _idSeq = 0;
@@ -480,14 +438,74 @@ class CompareSection extends LitElement {
480
438
  constructor() {
481
439
  super();
482
440
  this._sectionTitleId = `bd-compare-section-title-${++CompareSection._idSeq}`;
483
- this.title = "";
484
- this.subtitle = "";
485
- this.columns = 2;
486
- this.gap = "";
441
+ this.title = "";
442
+ this.subtitle = "";
443
+ this.columns = 2;
444
+ this.gap = "";
445
+ this._syncRaf = 0;
446
+ this._ro = null;
447
+ }
448
+
449
+ connectedCallback() {
450
+ super.connectedCallback();
451
+ // ResizeObserver — re-sync whenever any card changes size
452
+ this._ro = new ResizeObserver(() => this._syncHeaderHeights());
453
+ this._ro.observe(this);
454
+ }
455
+
456
+ disconnectedCallback() {
457
+ super.disconnectedCallback();
458
+ this._ro?.disconnect();
459
+ this._ro = null;
460
+ if (this._syncRaf) cancelAnimationFrame(this._syncRaf);
487
461
  }
488
462
 
489
463
  firstUpdated() {
490
464
  scheduleCompareIntroDone();
465
+ // Initial sync after first paint
466
+ requestAnimationFrame(() => this._syncHeaderHeights());
467
+ }
468
+
469
+ /**
470
+ * Reads all compare-card children, finds the tallest .card-header
471
+ * across all of them, then sets that height as minHeight on every card.
472
+ * Runs in a rAF to batch DOM reads before writes.
473
+ */
474
+ _syncHeaderHeights() {
475
+ if (this._syncRaf) cancelAnimationFrame(this._syncRaf);
476
+ this._syncRaf = requestAnimationFrame(() => {
477
+ this._syncRaf = 0;
478
+
479
+ const isMobile = window.matchMedia("(max-width: 767px)").matches;
480
+
481
+ // Get all slotted compare-card elements
482
+ const slot = this.shadowRoot?.querySelector("slot");
483
+ if (!slot) return;
484
+
485
+ const cards = slot
486
+ .assignedElements({ flatten: true })
487
+ .filter((el) => el.localName === "compare-card");
488
+
489
+ if (!cards.length) return;
490
+
491
+ // On mobile reset all — no alignment needed
492
+ if (isMobile) {
493
+ cards.forEach((c) => c.setHeaderMinHeight?.(null));
494
+ return;
495
+ }
496
+
497
+ // Reset first so we get natural heights
498
+ cards.forEach((c) => c.setHeaderMinHeight?.(null));
499
+
500
+ // Read heights after reset
501
+ requestAnimationFrame(() => {
502
+ const heights = cards.map((c) => c.getHeaderHeight?.() ?? 0);
503
+ const maxH = Math.max(...heights);
504
+
505
+ // Write — set all to tallest
506
+ cards.forEach((c) => c.setHeaderMinHeight?.(maxH));
507
+ });
508
+ });
491
509
  }
492
510
 
493
511
  render() {
@@ -503,27 +521,25 @@ class CompareSection extends LitElement {
503
521
  >
504
522
  ${this.title || this.subtitle
505
523
  ? html`
506
- <div class="cs-heading">
507
- ${this.title
508
- ? html`
509
- ${isMobile
510
- ? html`<bd-h as="h4" class="cs-title" heading-id="${this._sectionTitleId}">${this.title}</bd-h>`
511
- : html`<bd-h as="h3" class="cs-title" heading-id="${this._sectionTitleId}">${this.title}</bd-h>`}
512
- `
513
- : ""}
514
- ${this.subtitle
515
- ? html`
516
- <bd-p kind="${isMobile ? "small" : "regular"}" class="cs-subtitle">
517
- ${this.subtitle}
518
- </bd-p>
519
- `
520
- : ""}
521
- </div>
522
- `
524
+ <div class="cs-heading">
525
+ ${this.title
526
+ ? isMobile
527
+ ? html`<bd-h as="h4" class="cs-title" heading-id="${this._sectionTitleId}">${this.title}</bd-h>`
528
+ : html`<bd-h as="h3" class="cs-title" heading-id="${this._sectionTitleId}">${this.title}</bd-h>`
529
+ : ""}
530
+ ${this.subtitle
531
+ ? html`
532
+ <bd-p kind="${isMobile ? "small" : "regular"}" class="cs-subtitle">
533
+ ${this.subtitle}
534
+ </bd-p>
535
+ `
536
+ : ""}
537
+ </div>
538
+ `
523
539
  : ""}
524
540
 
525
541
  <div class="cs-grid" style="${gapStyle}">
526
- <slot></slot>
542
+ <slot @slotchange=${this._syncHeaderHeights}></slot>
527
543
  </div>
528
544
  </section>
529
545
  `;