@repobit/dex-system-design 0.23.6 → 0.23.8

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