@repobit/dex-system-design 0.23.6 → 0.23.7

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.
@@ -1,8 +1,8 @@
1
- import { LitElement, html } from "lit";
2
- import "../../components/image/image.js";
1
+ import { LitElement, html, nothing } from "lit";
3
2
  import { tokens } from "../../tokens/tokens.js";
3
+ import "../image/image.js";
4
4
  import "../link/link.js";
5
- import compareCSS from "./compare.css";
5
+ import compareCSS from "./compare.css.js";
6
6
 
7
7
  // ═══════════════════════════════════════════════════════════════
8
8
  // compare-bar
@@ -12,50 +12,241 @@ import compareCSS from "./compare.css";
12
12
  // max-score {Number} – Max possible score (default: 6)
13
13
  // variant {String} – "primary" | "secondary"
14
14
  // score-label {String} – Optional override text for the score
15
+ // reference-score {Number} – Baseline score (e.g. Bitdefender). With stretch
16
+ // (set by compare-card), width maps [r_min,1] → [fill_min,100%].
17
+ // scale {Number} – Only used when reference-score is unset (× max-score %)
15
18
  // ═══════════════════════════════════════════════════════════════
16
19
 
20
+ function readStretchVars(el) {
21
+ const styles = getComputedStyle(el);
22
+ return {
23
+ stretch: styles.getPropertyValue("--cs-bar-stretch").trim() !== "0",
24
+ rMinStr: styles.getPropertyValue("--cs-stretch-r-min").trim(),
25
+ fillMin: parseFloat(styles.getPropertyValue("--cs-bar-fill-min").trim()),
26
+ gamma : parseFloat(styles.getPropertyValue("--cs-bar-stretch-gamma").trim())
27
+ };
28
+ }
29
+
30
+ const SCORE_ANIM_MS = 800;
31
+ const SCORE_ANIM_EASING = (t) => 1 - (1 - t) ** 3;
32
+
33
+ /** After first paint of this document, intro is “consumed” (scroll-back won’t replay). */
34
+ let compareIntroDone = false;
35
+ /** @type {ReturnType<typeof setTimeout> | 0} */
36
+ let compareIntroTimer = 0;
37
+
38
+ function shouldPlayCompareIntro() {
39
+ return !compareIntroDone;
40
+ }
41
+
42
+ function scheduleCompareIntroDone() {
43
+ if (compareIntroDone || compareIntroTimer) return;
44
+ compareIntroTimer = setTimeout(() => {
45
+ compareIntroTimer = 0;
46
+ compareIntroDone = true;
47
+ }, SCORE_ANIM_MS + 120);
48
+ }
49
+
50
+ function prefersReducedMotion() {
51
+ try {
52
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ if (typeof globalThis !== "undefined" && prefersReducedMotion()) {
59
+ compareIntroDone = true;
60
+ }
61
+
17
62
  class CompareBar extends LitElement {
18
63
  static properties = {
19
- label : { type: String },
20
- score : { type: Number },
21
- maxScore : { type: Number, attribute: "max-score" },
22
- variant : { type: String },
23
- scoreLabel: { type: String, attribute: "score-label" }
64
+ label : { type: String },
65
+ score : { type: Number },
66
+ maxScore : { type: Number, attribute: "max-score" },
67
+ variant : { type: String },
68
+ scoreLabel : { type: String, attribute: "score-label" },
69
+ referenceScore: { type: Number, attribute: "reference-score" },
70
+ scale : { type: Number },
71
+ _animatedScore: { state: true }
24
72
  };
25
73
 
26
74
  static styles = [tokens, compareCSS];
27
75
 
28
76
  constructor() {
29
77
  super();
30
- this.label = "";
31
- this.score = 0;
32
- this.maxScore = 6;
33
- this.variant = "secondary";
78
+ this.label = "";
79
+ this.score = 0;
80
+ this.maxScore = 6;
81
+ this.variant = "secondary";
34
82
  this.scoreLabel = "";
83
+ this.referenceScore = 0;
84
+ this.scale = 1;
85
+ this._animatedScore = NaN;
86
+ /** @type {number} */
87
+ this._scoreAnimRaf = 0;
35
88
  }
36
89
 
37
- get _pct() {
38
- const s = parseFloat(this.score) || 0;
39
- const m = parseFloat(this.maxScore) || 6;
40
- if (m === 0) return 0;
90
+ connectedCallback() {
91
+ super.connectedCallback();
92
+ this.setAttribute("aria-label", this._barAriaLabel());
93
+ if (!shouldPlayCompareIntro()) {
94
+ this.classList.add("compare-no-motion");
95
+ return;
96
+ }
97
+ scheduleCompareIntroDone();
98
+ if (this._shouldAnimateCompetitorScore()) {
99
+ this._animatedScore = this._scoreAnimStart();
100
+ }
101
+ }
102
+
103
+ disconnectedCallback() {
104
+ super.disconnectedCallback();
105
+ this._cancelScoreAnim();
106
+ }
107
+
108
+ updated(changed) {
109
+ super.updated(changed);
110
+ this.setAttribute("aria-label", this._barAriaLabel());
111
+ const deps = [
112
+ "score",
113
+ "referenceScore",
114
+ "scoreLabel",
115
+ "variant",
116
+ "maxScore",
117
+ "label"
118
+
119
+ ];
120
+ if (!deps.some((k) => changed.has(k))) return;
121
+
122
+ this._cancelScoreAnim();
123
+ if (!shouldPlayCompareIntro()) {
124
+ this.classList.add("compare-no-motion");
125
+ this._animatedScore = NaN;
126
+ return;
127
+ }
128
+ if (this._shouldAnimateCompetitorScore()) {
129
+ this._animatedScore = this._scoreAnimStart();
130
+ this._runScoreAnim();
131
+ } else {
132
+ this._animatedScore = NaN;
133
+ }
134
+ }
135
+
136
+ _shouldAnimateCompetitorScore() {
137
+ if (this.variant === "primary" || this.scoreLabel) return false;
138
+ const t = parseFloat(this.score);
139
+ return Number.isFinite(t);
140
+ }
141
+
142
+ _scoreAnimStart() {
143
+ const target = parseFloat(this.score);
144
+ const ref = parseFloat(this.referenceScore);
145
+ const maxM = parseFloat(this.maxScore) || 6;
146
+ let start = ref > 0 ? ref : maxM;
147
+ if (start <= target) start = Math.min(maxM, target + 0.2);
148
+ return start;
149
+ }
150
+
151
+ _cancelScoreAnim() {
152
+ if (this._scoreAnimRaf) {
153
+ cancelAnimationFrame(this._scoreAnimRaf);
154
+ this._scoreAnimRaf = 0;
155
+ }
156
+ }
157
+
158
+ _runScoreAnim() {
159
+ const target = parseFloat(this.score);
160
+ const start = this._animatedScore;
161
+ if (!Number.isFinite(target) || !Number.isFinite(start)) return;
162
+
163
+ const t0 = performance.now();
164
+
165
+ const tick = (now) => {
166
+ const elapsed = now - t0;
167
+ const u = Math.min(1, elapsed / SCORE_ANIM_MS);
168
+ const e = SCORE_ANIM_EASING(u);
169
+ const v = start + (target - start) * e;
170
+ this._animatedScore = v;
171
+ if (u < 1) {
172
+ this._scoreAnimRaf = requestAnimationFrame(tick);
173
+ } else {
174
+ this._scoreAnimRaf = 0;
175
+ this._animatedScore = NaN;
176
+ this.requestUpdate();
177
+ }
178
+ };
179
+
180
+ this._scoreAnimRaf = requestAnimationFrame(tick);
181
+ }
41
182
 
183
+ get _pct() {
184
+ const s = parseFloat(this.score) || 0;
42
185
  if (this.variant === "primary") return 100;
43
186
 
187
+ const ref = parseFloat(this.referenceScore);
188
+ if (ref > 0) {
189
+ const r = Math.min(1, Math.max(0, s / ref));
190
+ const linearPct = r * 100;
191
+ const { stretch, rMinStr, fillMin, gamma } = readStretchVars(this);
192
+
193
+ if (!stretch || !rMinStr || rMinStr === "none") {
194
+ return Math.min(100, Math.max(0, linearPct));
195
+ }
196
+
197
+ const rMin = parseFloat(rMinStr);
198
+ if (Number.isNaN(rMin) || rMin >= 1 - 1e-9) {
199
+ return Math.min(100, Math.max(0, linearPct));
200
+ }
201
+
202
+ const denom = 1 - rMin;
203
+ const wMin = Number.isFinite(fillMin) ? fillMin : 50;
204
+ const g = Number.isFinite(gamma) && gamma > 0 ? gamma : 1;
205
+ const u = (r - rMin) / denom;
206
+ const t = Math.min(1, Math.max(0, u));
207
+ const shaped = Math.pow(t, g);
208
+ const stretched = wMin + shaped * (100 - wMin);
209
+ return Math.min(100, Math.max(0, stretched));
210
+ }
211
+
212
+ const m = parseFloat(this.maxScore) || 6;
213
+ if (m === 0) return 0;
44
214
  const rawPct = Math.min(100, Math.max(0, (s / m) * 100));
45
- return rawPct * 0.75;
215
+ return rawPct * (this.scale || 1);
46
216
  }
47
217
 
48
218
  get _displayScore() {
49
219
  return this.scoreLabel || this.score;
50
220
  }
51
221
 
222
+ _renderScoreText() {
223
+ if (this.variant === "primary" || this.scoreLabel) {
224
+ return this._displayScore;
225
+ }
226
+ if (Number.isFinite(this._animatedScore)) {
227
+ return CompareBar._formatScoreTick(this._animatedScore);
228
+ }
229
+ return this._displayScore;
230
+ }
231
+
232
+ /** @param {number} v */
233
+ static _formatScoreTick(v) {
234
+ return (Math.round(v * 100) / 100).toFixed(2);
235
+ }
236
+
237
+ /** Accessible name uses final numeric score (not the intro animation value). */
238
+ _barAriaLabel() {
239
+ const max = this.maxScore ?? 6;
240
+ const val = this.scoreLabel || this.score;
241
+ return `${this.label}, score ${val} out of ${max}`;
242
+ }
243
+
52
244
  render() {
53
245
  return html`
54
246
  <div class="bar-track">
55
- <div class="bar-fill" style="width: ${this._pct}%"></div>
56
- <div class="bar-content">
57
- <span class="bar-label">${this.label}</span>
58
- <span class="bar-score">${this._displayScore}</span>
247
+ <div class="bar-fill" style="width: ${this._pct}%">
248
+ <span class="bar-label" aria-hidden="true">${this.label}</span>
249
+ <span class="bar-score" aria-hidden="true">${this._renderScoreText()}</span>
59
250
  </div>
60
251
  </div>
61
252
  `;
@@ -64,42 +255,128 @@ class CompareBar extends LitElement {
64
255
 
65
256
  customElements.define("compare-bar", CompareBar);
66
257
 
67
-
68
258
  // ═══════════════════════════════════════════════════════════════
69
259
  // compare-card
70
- // Attributes / Properties:
71
- // title {String} – Card heading
72
- // description {String} – Explanatory subtitle
73
- // footnote {String} – Small source text at the bottom
74
- // footnote-href {String} – URL for the linked part of the footnote
75
- // icon-src {String} – Path to icon asset for bd-img
76
- //
77
- // Slots:
78
- // (default) – <compare-bar> elements
79
260
  // ═══════════════════════════════════════════════════════════════
80
261
 
81
262
  class CompareCard extends LitElement {
263
+ static _idSeq = 0;
264
+
82
265
  static properties = {
83
- title : { type: String },
84
- description : { type: String },
85
- footnote : { type: String },
86
- footnoteHref: { type: String, attribute: "footnote-href" },
87
- iconSrc : { type: String, attribute: "icon-src" }
266
+ title : { type: String },
267
+ description : { type: String },
268
+ footnote : { type: String },
269
+ footnoteHref : { type: String, attribute: "footnote-href" },
270
+ iconSrc : { type: String, attribute: "icon-src" },
271
+ /** When true, competitor bar widths use stretched mapping so small score gaps read clearly. */
272
+ barStretch : { type: Boolean, attribute: "bar-stretch" },
273
+ /** Visual floor % for the weakest competitor (rest of span goes to 100% / Bitdefender). */
274
+ barFillMin : { type: Number, attribute: "bar-fill-min" },
275
+ /**
276
+ * Curve on normalized rank within [r_min, 1]. >1 pulls stronger competitors down vs Bitdefender
277
+ * (bigger BD gap); =1 is pure linear spread.
278
+ */
279
+ barStretchGamma: { type: Number, attribute: "bar-stretch-gamma" },
280
+ /** Shown only in the screen-reader chart hint (scores are “out of” this scale). */
281
+ chartScale : { type: Number, attribute: "chart-scale" }
88
282
  };
89
283
 
90
284
  static styles = [tokens, compareCSS];
91
285
 
92
286
  constructor() {
93
287
  super();
94
- this.title = "";
95
- this.description = "";
96
- this.footnote = "";
288
+ const n = ++CompareCard._idSeq;
289
+ this._headingId = `compare-card-title-${n}`;
290
+ this._chartHelpId = `compare-card-chart-${n}`;
291
+ this.title = "";
292
+ this.description = "";
293
+ this.footnote = "";
97
294
  this.footnoteHref = "";
98
- this.iconSrc = "";
295
+ this.iconSrc = "";
296
+ this.barStretch = true;
297
+ this.barFillMin = 50;
298
+ this.barStretchGamma = 1;
299
+ this.chartScale = 6;
300
+ }
301
+
302
+ firstUpdated() {
303
+ this._syncBarStretch();
304
+ }
305
+
306
+ updated(changed) {
307
+ super.updated(changed);
308
+ if (
309
+ changed.has("barFillMin") ||
310
+ changed.has("barStretchGamma") ||
311
+ changed.has("barStretch")
312
+ ) {
313
+ this._syncBarStretch();
314
+ }
315
+ }
316
+
317
+ _syncBarStretch() {
318
+ const slot = this.shadowRoot?.querySelector("slot");
319
+ if (!slot) return;
320
+
321
+ const apply = () => {
322
+ const nodes = slot.assignedElements({ flatten: true });
323
+ const bars = nodes.filter(
324
+ (el) => el.localName === "compare-bar"
325
+ );
326
+
327
+ if (!this.barStretch) {
328
+ this.style.setProperty("--cs-bar-stretch", "0");
329
+ this._refreshBars(bars);
330
+ return;
331
+ }
332
+
333
+ const primary = bars.find((b) => b.variant === "primary");
334
+ const ref = primary ? parseFloat(primary.score) : 0;
335
+
336
+ if (!ref || ref <= 0) {
337
+ this.style.setProperty("--cs-bar-stretch", "0");
338
+ this._refreshBars(bars);
339
+ return;
340
+ }
341
+
342
+ const secondaries = bars.filter((b) => b.variant !== "primary");
343
+ if (secondaries.length < 2) {
344
+ this.style.setProperty("--cs-bar-stretch", "0");
345
+ this._refreshBars(bars);
346
+ return;
347
+ }
348
+
349
+ let rMin = 1;
350
+ for (const b of bars) {
351
+ if (b.variant === "primary") continue;
352
+ const sc = parseFloat(b.score);
353
+ if (!Number.isNaN(sc)) rMin = Math.min(rMin, sc / ref);
354
+ }
355
+
356
+ if (rMin >= 1 - 1e-6) {
357
+ this.style.setProperty("--cs-bar-stretch", "0");
358
+ } else {
359
+ this.style.setProperty("--cs-bar-stretch", "1");
360
+ this.style.setProperty("--cs-stretch-r-min", String(rMin));
361
+ this.style.setProperty("--cs-bar-fill-min", String(this.barFillMin));
362
+ this.style.setProperty(
363
+ "--cs-bar-stretch-gamma",
364
+ String(this.barStretchGamma)
365
+ );
366
+ }
367
+
368
+ this._refreshBars(bars);
369
+ };
370
+
371
+ apply();
372
+ }
373
+
374
+ _refreshBars(bars) {
375
+ queueMicrotask(() => {
376
+ bars.forEach((b) => b.requestUpdate?.());
377
+ });
99
378
  }
100
379
 
101
- // Splits footnote text on "Source " and wraps everything after
102
- // it in a bd-link. Falls back to plain text if no href given.
103
380
  _renderFootnote() {
104
381
  if (!this.footnote) return "";
105
382
 
@@ -111,7 +388,6 @@ class CompareCard extends LitElement {
111
388
  const idx = this.footnote.indexOf(splitOn);
112
389
 
113
390
  if (idx === -1) {
114
- // No "Source " found — link the whole footnote text
115
391
  return html`
116
392
  <bd-p kind="xsmall" class="card-footnote">
117
393
  <bd-link href="${this.footnoteHref}" target="_blank" font-size="12px" color="var(--color-neutral-900)" underline>
@@ -121,7 +397,7 @@ class CompareCard extends LitElement {
121
397
  `;
122
398
  }
123
399
 
124
- const before = this.footnote.slice(0, idx + splitOn.length);
400
+ const before = this.footnote.slice(0, idx + splitOn.length);
125
401
  const linkedText = this.footnote.slice(idx + splitOn.length);
126
402
 
127
403
  return html`
@@ -135,17 +411,24 @@ class CompareCard extends LitElement {
135
411
  const isMobile = window.matchMedia("(max-width: 767px)").matches;
136
412
 
137
413
  return html`
138
- <div class="card">
414
+ <div
415
+ class="card"
416
+ role="article"
417
+ aria-labelledby="${this._headingId}"
418
+ aria-describedby="${this._chartHelpId}"
419
+ >
420
+ <p class="cs-sr-only" id="${this._chartHelpId}">
421
+ Bar lengths are scaled for visibility between brands. Use the announced scores for exact values; scale is out of ${this.chartScale}.
422
+ </p>
139
423
  <div class="card-header">
140
-
141
424
  ${this.iconSrc
142
425
  ? html`
143
426
  <div class="card-icon-wrap" aria-hidden="true">
144
427
  <bd-img
145
428
  src="${this.iconSrc}"
146
429
  alt=""
147
- width="42"
148
- height="39"
430
+ width="48"
431
+ height="48"
149
432
  fit="contain"
150
433
  radius="none"
151
434
  shadow="none"
@@ -157,9 +440,8 @@ class CompareCard extends LitElement {
157
440
 
158
441
  <div class="card-text-wrap">
159
442
  ${isMobile
160
- ? html`<bd-h as="h5" class="card-title">${this.title}</bd-h>`
161
- : html`<bd-h as="h4" class="card-title">${this.title}</bd-h>`
162
- }
443
+ ? html`<bd-h as="h5" class="card-title" heading-id="${this._headingId}">${this.title}</bd-h>`
444
+ : html`<bd-h as="h4" class="card-title" heading-id="${this._headingId}">${this.title}</bd-h>`}
163
445
  ${this.description
164
446
  ? html`
165
447
  <bd-p kind="${isMobile ? "small" : "regular"}" class="card-description">
@@ -168,11 +450,10 @@ class CompareCard extends LitElement {
168
450
  `
169
451
  : ""}
170
452
  </div>
171
-
172
453
  </div>
173
454
 
174
- <div class="card-bars">
175
- <slot></slot>
455
+ <div class="card-bars" role="group" aria-label="Scores by brand">
456
+ <slot @slotchange=${this._syncBarStretch}></slot>
176
457
  </div>
177
458
 
178
459
  ${this._renderFootnote()}
@@ -183,20 +464,13 @@ class CompareCard extends LitElement {
183
464
 
184
465
  customElements.define("compare-card", CompareCard);
185
466
 
186
-
187
467
  // ═══════════════════════════════════════════════════════════════
188
468
  // compare-section
189
- // Attributes / Properties:
190
- // title {String} – Section heading
191
- // subtitle {String} – Optional subheading
192
- // columns {Number} – Column count override (default: 2)
193
- // gap {String} – Gap between cards e.g. "16px" or "var(--spacing-4)"
194
- //
195
- // Slots:
196
- // (default) – <compare-card> elements
197
469
  // ═══════════════════════════════════════════════════════════════
198
470
 
199
471
  class CompareSection extends LitElement {
472
+ static _idSeq = 0;
473
+
200
474
  static properties = {
201
475
  title : { type: String },
202
476
  subtitle: { type: String },
@@ -208,10 +482,15 @@ class CompareSection extends LitElement {
208
482
 
209
483
  constructor() {
210
484
  super();
211
- this.title = "";
485
+ this._sectionTitleId = `bd-compare-section-title-${++CompareSection._idSeq}`;
486
+ this.title = "";
212
487
  this.subtitle = "";
213
- this.columns = 2;
214
- this.gap = "";
488
+ this.columns = 2;
489
+ this.gap = "";
490
+ }
491
+
492
+ firstUpdated() {
493
+ scheduleCompareIntroDone();
215
494
  }
216
495
 
217
496
  render() {
@@ -221,16 +500,18 @@ class CompareSection extends LitElement {
221
500
  : `--_cols: ${this.columns}`;
222
501
 
223
502
  return html`
224
- <section class="cs-section">
503
+ <section
504
+ class="cs-section"
505
+ aria-labelledby="${this.title ? this._sectionTitleId : nothing}"
506
+ >
225
507
  ${this.title || this.subtitle
226
508
  ? html`
227
509
  <div class="cs-heading">
228
510
  ${this.title
229
511
  ? html`
230
512
  ${isMobile
231
- ? html`<bd-h as="h4" class="cs-title">${this.title}</bd-h>`
232
- : html`<bd-h as="h3" class="cs-title">${this.title}</bd-h>`
233
- }
513
+ ? html`<bd-h as="h4" class="cs-title" heading-id="${this._sectionTitleId}">${this.title}</bd-h>`
514
+ : html`<bd-h as="h3" class="cs-title" heading-id="${this._sectionTitleId}">${this.title}</bd-h>`}
234
515
  `
235
516
  : ""}
236
517
  ${this.subtitle