@repobit/dex-system-design 0.23.5 → 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,46 +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;
88
+ }
89
+
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);
35
181
  }
36
182
 
37
183
  get _pct() {
38
- const s = parseFloat(this.score) || 0;
184
+ const s = parseFloat(this.score) || 0;
185
+ if (this.variant === "primary") return 100;
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
+
39
212
  const m = parseFloat(this.maxScore) || 6;
40
213
  if (m === 0) return 0;
41
- return Math.min(100, Math.max(0, (s / m) * 100));
214
+ const rawPct = Math.min(100, Math.max(0, (s / m) * 100));
215
+ return rawPct * (this.scale || 1);
42
216
  }
43
217
 
44
218
  get _displayScore() {
45
219
  return this.scoreLabel || this.score;
46
220
  }
47
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
+
48
244
  render() {
49
245
  return html`
50
246
  <div class="bar-track">
51
- <div class="bar-fill" style="width: ${this._pct}%"></div>
52
- <div class="bar-content">
53
- <span class="bar-label">${this.label}</span>
54
- <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>
55
250
  </div>
56
251
  </div>
57
252
  `;
@@ -60,42 +255,128 @@ class CompareBar extends LitElement {
60
255
 
61
256
  customElements.define("compare-bar", CompareBar);
62
257
 
63
-
64
258
  // ═══════════════════════════════════════════════════════════════
65
259
  // compare-card
66
- // Attributes / Properties:
67
- // title {String} – Card heading
68
- // description {String} – Explanatory subtitle
69
- // footnote {String} – Small source text at the bottom
70
- // footnote-href {String} – URL for the linked part of the footnote
71
- // icon-src {String} – Path to icon asset for bd-img
72
- //
73
- // Slots:
74
- // (default) – <compare-bar> elements
75
260
  // ═══════════════════════════════════════════════════════════════
76
261
 
77
262
  class CompareCard extends LitElement {
263
+ static _idSeq = 0;
264
+
78
265
  static properties = {
79
- title : { type: String },
80
- description : { type: String },
81
- footnote : { type: String },
82
- footnoteHref: { type: String, attribute: "footnote-href" },
83
- 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" }
84
282
  };
85
283
 
86
284
  static styles = [tokens, compareCSS];
87
285
 
88
286
  constructor() {
89
287
  super();
90
- this.title = "";
91
- this.description = "";
92
- 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 = "";
93
294
  this.footnoteHref = "";
94
- 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
+ });
95
378
  }
96
379
 
97
- // Splits footnote text on "Source " and wraps everything after
98
- // it in a bd-link. Falls back to plain text if no href given.
99
380
  _renderFootnote() {
100
381
  if (!this.footnote) return "";
101
382
 
@@ -107,7 +388,6 @@ class CompareCard extends LitElement {
107
388
  const idx = this.footnote.indexOf(splitOn);
108
389
 
109
390
  if (idx === -1) {
110
- // No "Source " found — link the whole footnote text
111
391
  return html`
112
392
  <bd-p kind="xsmall" class="card-footnote">
113
393
  <bd-link href="${this.footnoteHref}" target="_blank" font-size="12px" color="var(--color-neutral-900)" underline>
@@ -117,7 +397,7 @@ class CompareCard extends LitElement {
117
397
  `;
118
398
  }
119
399
 
120
- const before = this.footnote.slice(0, idx + splitOn.length);
400
+ const before = this.footnote.slice(0, idx + splitOn.length);
121
401
  const linkedText = this.footnote.slice(idx + splitOn.length);
122
402
 
123
403
  return html`
@@ -131,17 +411,24 @@ class CompareCard extends LitElement {
131
411
  const isMobile = window.matchMedia("(max-width: 767px)").matches;
132
412
 
133
413
  return html`
134
- <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>
135
423
  <div class="card-header">
136
-
137
424
  ${this.iconSrc
138
425
  ? html`
139
426
  <div class="card-icon-wrap" aria-hidden="true">
140
427
  <bd-img
141
428
  src="${this.iconSrc}"
142
429
  alt=""
143
- width="42"
144
- height="39"
430
+ width="48"
431
+ height="48"
145
432
  fit="contain"
146
433
  radius="none"
147
434
  shadow="none"
@@ -153,22 +440,20 @@ class CompareCard extends LitElement {
153
440
 
154
441
  <div class="card-text-wrap">
155
442
  ${isMobile
156
- ? html`<bd-h as="h5" class="card-title">${this.title}</bd-h>`
157
- : html`<bd-h as="h4" class="card-title">${this.title}</bd-h>`
158
- }
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>`}
159
445
  ${this.description
160
446
  ? html`
161
- <bd-p kind="${isMobile ? "small" : "large"}" class="card-description">
447
+ <bd-p kind="${isMobile ? "small" : "regular"}" class="card-description">
162
448
  ${this.description}
163
449
  </bd-p>
164
450
  `
165
451
  : ""}
166
452
  </div>
167
-
168
453
  </div>
169
454
 
170
- <div class="card-bars">
171
- <slot></slot>
455
+ <div class="card-bars" role="group" aria-label="Scores by brand">
456
+ <slot @slotchange=${this._syncBarStretch}></slot>
172
457
  </div>
173
458
 
174
459
  ${this._renderFootnote()}
@@ -179,20 +464,13 @@ class CompareCard extends LitElement {
179
464
 
180
465
  customElements.define("compare-card", CompareCard);
181
466
 
182
-
183
467
  // ═══════════════════════════════════════════════════════════════
184
468
  // compare-section
185
- // Attributes / Properties:
186
- // title {String} – Section heading
187
- // subtitle {String} – Optional subheading
188
- // columns {Number} – Column count override (default: 2)
189
- // gap {String} – Gap between cards e.g. "16px" or "var(--spacing-4)"
190
- //
191
- // Slots:
192
- // (default) – <compare-card> elements
193
469
  // ═══════════════════════════════════════════════════════════════
194
470
 
195
471
  class CompareSection extends LitElement {
472
+ static _idSeq = 0;
473
+
196
474
  static properties = {
197
475
  title : { type: String },
198
476
  subtitle: { type: String },
@@ -204,10 +482,15 @@ class CompareSection extends LitElement {
204
482
 
205
483
  constructor() {
206
484
  super();
207
- this.title = "";
485
+ this._sectionTitleId = `bd-compare-section-title-${++CompareSection._idSeq}`;
486
+ this.title = "";
208
487
  this.subtitle = "";
209
- this.columns = 2;
210
- this.gap = "";
488
+ this.columns = 2;
489
+ this.gap = "";
490
+ }
491
+
492
+ firstUpdated() {
493
+ scheduleCompareIntroDone();
211
494
  }
212
495
 
213
496
  render() {
@@ -217,16 +500,18 @@ class CompareSection extends LitElement {
217
500
  : `--_cols: ${this.columns}`;
218
501
 
219
502
  return html`
220
- <section class="cs-section">
503
+ <section
504
+ class="cs-section"
505
+ aria-labelledby="${this.title ? this._sectionTitleId : nothing}"
506
+ >
221
507
  ${this.title || this.subtitle
222
508
  ? html`
223
509
  <div class="cs-heading">
224
510
  ${this.title
225
511
  ? html`
226
512
  ${isMobile
227
- ? html`<bd-h as="h4" class="cs-title">${this.title}</bd-h>`
228
- : html`<bd-h as="h3" class="cs-title">${this.title}</bd-h>`
229
- }
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>`}
230
515
  `
231
516
  : ""}
232
517
  ${this.subtitle