@jjlmoya/utils-sports 1.32.0 → 1.34.0

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.
Files changed (60) hide show
  1. package/package.json +4 -2
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +2 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  7. package/src/tests/tool_validation.test.ts +1 -1
  8. package/src/tool/baseballScoreKeeper/component.astro +2 -2
  9. package/src/tool/basketScoreKeeper/component.astro +2 -2
  10. package/src/tool/basketScoreKeeper/logic.ts +13 -7
  11. package/src/tool/beachVolleyballScoreKeeper/component.astro +4 -4
  12. package/src/tool/beachVolleyballScoreKeeper/logic.ts +4 -6
  13. package/src/tool/beachVolleyballScoreKeeper/render.ts +30 -31
  14. package/src/tool/dartsScoreKeeper/component.astro +2 -2
  15. package/src/tool/eloRatingCalculator/bibliography.astro +6 -0
  16. package/src/tool/eloRatingCalculator/bibliography.ts +16 -0
  17. package/src/tool/eloRatingCalculator/component.astro +270 -0
  18. package/src/tool/eloRatingCalculator/elo-rating-calculator.css +495 -0
  19. package/src/tool/eloRatingCalculator/entry.ts +30 -0
  20. package/src/tool/eloRatingCalculator/i18n/de.ts +215 -0
  21. package/src/tool/eloRatingCalculator/i18n/en.ts +215 -0
  22. package/src/tool/eloRatingCalculator/i18n/es.ts +215 -0
  23. package/src/tool/eloRatingCalculator/i18n/fr.ts +215 -0
  24. package/src/tool/eloRatingCalculator/i18n/id.ts +215 -0
  25. package/src/tool/eloRatingCalculator/i18n/it.ts +215 -0
  26. package/src/tool/eloRatingCalculator/i18n/ja.ts +215 -0
  27. package/src/tool/eloRatingCalculator/i18n/ko.ts +215 -0
  28. package/src/tool/eloRatingCalculator/i18n/nl.ts +215 -0
  29. package/src/tool/eloRatingCalculator/i18n/pl.ts +215 -0
  30. package/src/tool/eloRatingCalculator/i18n/pt.ts +215 -0
  31. package/src/tool/eloRatingCalculator/i18n/ru.ts +215 -0
  32. package/src/tool/eloRatingCalculator/i18n/sv.ts +215 -0
  33. package/src/tool/eloRatingCalculator/i18n/tr.ts +215 -0
  34. package/src/tool/eloRatingCalculator/i18n/zh.ts +215 -0
  35. package/src/tool/eloRatingCalculator/index.ts +9 -0
  36. package/src/tool/eloRatingCalculator/logic.ts +46 -0
  37. package/src/tool/eloRatingCalculator/seo.astro +15 -0
  38. package/src/tool/eloRatingCalculator/ui.ts +39 -0
  39. package/src/tool/footballScoreKeeper/component.astro +2 -2
  40. package/src/tool/footballScoreKeeper/logic.ts +3 -6
  41. package/src/tool/gymTracker/component.astro +1 -1
  42. package/src/tool/padelScoreKeeper/component.astro +2 -2
  43. package/src/tool/pingPongScoreKeeper/component.astro +2 -2
  44. package/src/tool/pingPongScoreKeeper/logic.ts +3 -5
  45. package/src/tool/rugbyScoreKeeper/component.astro +4 -4
  46. package/src/tool/rugbyScoreKeeper/ui-init.ts +2 -3
  47. package/src/tool/scoreKeeper/component.astro +3 -3
  48. package/src/tool/snookerScoreKeeper/component.astro +2 -2
  49. package/src/tool/snookerScoreKeeper/logic.ts +1 -2
  50. package/src/tool/streetballScoreKeeper/component.astro +2 -2
  51. package/src/tool/streetballScoreKeeper/logic.ts +2 -4
  52. package/src/tool/streetballScoreKeeper/render.ts +5 -10
  53. package/src/tool/tennisScoreKeeper/component.astro +2 -2
  54. package/src/tool/tennisScoreKeeper/effects.ts +47 -0
  55. package/src/tool/tennisScoreKeeper/logic.ts +4 -3
  56. package/src/tool/tennisScoreKeeper/render.ts +2 -44
  57. package/src/tool/tournamentBracket/component.astro +2 -2
  58. package/src/tool/tournamentBracket/ui/bracket-desktop.ts +19 -13
  59. package/src/tool/tournamentBracket/ui/navigator.ts +15 -7
  60. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./data": "./src/data.ts",
10
- "./entries": "./src/entries.ts"
10
+ "./entries": "./src/entries.ts",
11
+ "./runtime/*": "./src/tool/*/index.ts",
12
+ "./category-seo": "./src/category/seo.astro"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -5,6 +5,7 @@ import { scoreKeeper } from '../tool/scoreKeeper/index';
5
5
  import { tournamentBracket } from '../tool/tournamentBracket/index';
6
6
  import { gymTracker } from '../tool/gymTracker/index';
7
7
  import { reactionTester } from '../tool/reactionTester/index';
8
+ import { eloRatingCalculator } from '../tool/eloRatingCalculator/index';
8
9
  import { pingPongScoreKeeper } from '../tool/pingPongScoreKeeper/index';
9
10
  import { rugbyScoreKeeper } from '../tool/rugbyScoreKeeper/index';
10
11
  import { baseballScoreKeeper } from '../tool/baseballScoreKeeper/index';
@@ -18,6 +19,7 @@ export const sportsCategory: SportsCategoryEntry = {
18
19
  tournamentBracket,
19
20
  gymTracker,
20
21
  reactionTester,
22
+ eloRatingCalculator,
21
23
  pingPongScoreKeeper,
22
24
  rugbyScoreKeeper,
23
25
  baseballScoreKeeper,
package/src/entries.ts CHANGED
@@ -8,6 +8,8 @@ export { pingPongScoreKeeper } from './tool/pingPongScoreKeeper/entry';
8
8
  export type { PingPongScoreKeeperLocaleContent } from './tool/pingPongScoreKeeper/entry';
9
9
  export { reactionTester } from './tool/reactionTester/entry';
10
10
  export type { ReactionTesterLocaleContent } from './tool/reactionTester/entry';
11
+ export { eloRatingCalculator } from './tool/eloRatingCalculator/entry';
12
+ export type { EloRatingCalculatorLocaleContent } from './tool/eloRatingCalculator/entry';
11
13
  export { scoreKeeper } from './tool/scoreKeeper/entry';
12
14
  export type { ScoreKeeperLocaleContent } from './tool/scoreKeeper/entry';
13
15
  export { tournamentBracket } from './tool/tournamentBracket/entry';
@@ -34,6 +36,7 @@ import { footballScoreKeeper } from './tool/footballScoreKeeper/entry';
34
36
  import { gymTracker } from './tool/gymTracker/entry';
35
37
  import { pingPongScoreKeeper } from './tool/pingPongScoreKeeper/entry';
36
38
  import { reactionTester } from './tool/reactionTester/entry';
39
+ import { eloRatingCalculator } from './tool/eloRatingCalculator/entry';
37
40
  import { scoreKeeper } from './tool/scoreKeeper/entry';
38
41
  import { tournamentBracket } from './tool/tournamentBracket/entry';
39
42
  import { tennisScoreKeeper } from './tool/tennisScoreKeeper/entry';
@@ -44,5 +47,5 @@ import { beachVolleyballScoreKeeper } from './tool/beachVolleyballScoreKeeper/en
44
47
  import { snookerScoreKeeper } from './tool/snookerScoreKeeper/entry';
45
48
  import { rugbyScoreKeeper } from './tool/rugbyScoreKeeper/entry';
46
49
  import { baseballScoreKeeper } from './tool/baseballScoreKeeper/entry';
47
- export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, scoreKeeper, tournamentBracket, tennisScoreKeeper, dartsScoreKeeper, padelScoreKeeper, streetballScoreKeeper, beachVolleyballScoreKeeper, snookerScoreKeeper, rugbyScoreKeeper, baseballScoreKeeper];
50
+ export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, eloRatingCalculator, scoreKeeper, tournamentBracket, tennisScoreKeeper, dartsScoreKeeper, padelScoreKeeper, streetballScoreKeeper, beachVolleyballScoreKeeper, snookerScoreKeeper, rugbyScoreKeeper, baseballScoreKeeper];
48
51
 
package/src/index.ts CHANGED
@@ -29,3 +29,5 @@ export type { GymTrackerUI, GymTrackerLocaleContent } from './tool/gymTracker/in
29
29
  export { REACTION_TESTER_TOOL, reactionTester } from './tool/reactionTester/index';
30
30
  export type { ReactionTesterUI, ReactionTesterLocaleContent } from './tool/reactionTester/index';
31
31
 
32
+ export { ELO_RATING_CALCULATOR_TOOL, eloRatingCalculator } from './tool/eloRatingCalculator/index';
33
+ export type { EloRatingCalculatorUI, EloRatingCalculatorLocaleContent } from './tool/eloRatingCalculator/index';
@@ -3,6 +3,6 @@ import { ALL_TOOLS } from '../tools';
3
3
 
4
4
  describe('Locale Completeness Validation', () => {
5
5
  it('all tools registered', () => {
6
- expect(ALL_TOOLS.length).toBe(15);
6
+ expect(ALL_TOOLS.length).toBe(16);
7
7
  });
8
8
  });
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import type { SEOSection, ToolLocaleContent } from '../types';
6
+
7
+ const srcDir = join(process.cwd(), 'src');
8
+ const toolDir = join(srcDir, 'tool');
9
+ const geometryReads = [
10
+ 'offsetWidth',
11
+ 'offsetHeight',
12
+ 'offsetTop',
13
+ 'offsetLeft',
14
+ 'clientWidth',
15
+ 'clientHeight',
16
+ 'clientTop',
17
+ 'clientLeft',
18
+ 'scrollWidth',
19
+ 'scrollHeight',
20
+ 'scrollTop',
21
+ 'scrollLeft',
22
+ 'getBoundingClientRect',
23
+ 'getClientRects',
24
+ 'computedStyle',
25
+ 'getComputedStyle',
26
+ ];
27
+ const domWrites = [
28
+ '.style.',
29
+ '.classList.add',
30
+ '.classList.remove',
31
+ '.classList.toggle',
32
+ '.appendChild',
33
+ '.insertBefore',
34
+ '.prepend',
35
+ '.append',
36
+ '.remove',
37
+ '.innerHTML',
38
+ '.textContent',
39
+ '.setAttribute',
40
+ ];
41
+
42
+ function findFiles(dir: string, extensions: string[]): string[] {
43
+ const files: string[] = [];
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
47
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
48
+ }
49
+ return files;
50
+ }
51
+
52
+ function relativePath(file: string): string {
53
+ return relative(process.cwd(), file).replace(/\\/g, '/');
54
+ }
55
+
56
+ function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
57
+ return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
58
+ }
59
+
60
+ function attrValue(tag: string, attr: string): string | null {
61
+ const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
62
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
63
+ }
64
+
65
+ function booleanAttr(tag: string, attr: string): boolean {
66
+ return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
67
+ }
68
+
69
+ function controlStartIndex(content: string, tag: RegExpMatchArray): number {
70
+ return tag.index ?? content.indexOf(tag[0]);
71
+ }
72
+
73
+ function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
74
+ const index = controlStartIndex(content, tag);
75
+ const before = content.slice(0, index);
76
+ const labelOpen = before.lastIndexOf('<label');
77
+ const labelClose = before.lastIndexOf('</label>');
78
+ const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
79
+ return labelOpen > labelClose && nextLabelClose !== -1;
80
+ }
81
+
82
+ function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
83
+ const source = tag[0];
84
+ if (attrValue(source, 'aria-label')) return true;
85
+ if (attrValue(source, 'aria-labelledby')) return true;
86
+ const id = attrValue(source, 'id');
87
+ if (id && hasExplicitLabel(content, id)) return true;
88
+ return hasWrappingLabel(content, tag);
89
+ }
90
+
91
+ function isVisuallyHiddenFileInput(tag: string): boolean {
92
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
93
+ const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
94
+ const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
95
+ return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
96
+ }
97
+
98
+ function isIgnoredInput(tag: string): boolean {
99
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
100
+ return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
101
+ }
102
+
103
+ function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
104
+ return findFormControls(content, tagName)
105
+ .filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
106
+ .filter((tag) => !hasAccessibleName(content, tag))
107
+ .map((tag) => tag[0]);
108
+ }
109
+
110
+ function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
111
+ return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
112
+ }
113
+
114
+ function hasExplicitLabel(content: string, id: string): boolean {
115
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ return (
117
+ new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
118
+ || new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
119
+ );
120
+ }
121
+
122
+ function headingLevels(sections: SEOSection[]): number[] {
123
+ return sections
124
+ .filter((section) => section.type === 'title')
125
+ .map((section) => Number('level' in section ? section.level : 0))
126
+ .filter((level) => Number.isInteger(level) && level > 0);
127
+ }
128
+
129
+ function findHeadingLevelJumps(levels: number[]): string[] {
130
+ const failures: string[] = [];
131
+ levels.forEach((level, index) => {
132
+ const previous = index === 0 ? 1 : levels[index - 1];
133
+ if (previous && level > previous + 1) {
134
+ failures.push(`h${previous} -> h${level}`);
135
+ }
136
+ });
137
+ return failures;
138
+ }
139
+
140
+ function hasDomWriteBeforeGeometryRead(content: string): boolean {
141
+ const normalized = content.replace(/\s+/g, ' ');
142
+ return domWrites.some((write) => {
143
+ const writeIndex = normalized.indexOf(write);
144
+ if (writeIndex === -1) return false;
145
+ return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
146
+ });
147
+ }
148
+
149
+ describe('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });
@@ -5,7 +5,7 @@ import { sportsCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have 15 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(15);
8
+ expect(ALL_TOOLS.length).toBe(16);
9
9
  });
10
10
 
11
11
  it('sportsCategory should be defined', () => {
@@ -17,14 +17,14 @@ const t = (ui ?? {}) as BaseballScoreKeeperUI;
17
17
 
18
18
  <div class="bb-strip">
19
19
  <div class="bb-team">
20
- <input type="text" class="bb-name" id="bb-name-away" value={t.away} />
20
+ <input type="text" class="bb-name" id="bb-name-away" value={t.away} aria-label={t.away} />
21
21
  <div class="bb-score" id="bb-score-away">0</div>
22
22
  </div>
23
23
  <div class="bb-divider">
24
24
  <span class="bb-inning-badge" id="bb-inning-label">Top 1</span>
25
25
  </div>
26
26
  <div class="bb-team">
27
- <input type="text" class="bb-name" id="bb-name-home" value={t.home} />
27
+ <input type="text" class="bb-name" id="bb-name-home" value={t.home} aria-label={t.home} />
28
28
  <div class="bb-score" id="bb-score-home">0</div>
29
29
  </div>
30
30
  </div>
@@ -33,7 +33,7 @@ const t = (ui ?? {}) as BasketScoreKeeperUI;
33
33
 
34
34
  <div class="bk-team" id="bk-team-a">
35
35
  <div class="bk-team-top">
36
- <input type="text" value={t.playerA} class="bk-name" />
36
+ <input type="text" value={t.playerA} class="bk-name" aria-label={t.playerA} />
37
37
  <div id="bk-fire-a" class="bk-fire"></div>
38
38
  <div id="bk-streak-a" class="bk-streak"><span id="bk-streak-val-a" class="bk-streak-val"></span></div>
39
39
  </div>
@@ -59,7 +59,7 @@ const t = (ui ?? {}) as BasketScoreKeeperUI;
59
59
  <div class="bk-team-top bk-team-top-right">
60
60
  <div id="bk-streak-b" class="bk-streak"><span id="bk-streak-val-b" class="bk-streak-val"></span></div>
61
61
  <div id="bk-fire-b" class="bk-fire"></div>
62
- <input type="text" value={t.playerB} class="bk-name bk-name-right" />
62
+ <input type="text" value={t.playerB} class="bk-name bk-name-right" aria-label={t.playerB} />
63
63
  </div>
64
64
  <div id="bk-particles-b" class="bk-particles"></div>
65
65
  <div class="bk-score-stack" id="bk-stack-b">
@@ -1,5 +1,6 @@
1
1
  import type { BasketScoreKeeperUI } from './ui';
2
2
  const g = (id: string) => document.getElementById(id);
3
+ const _bcr = (el: Element) => el.getBoundingClientRect();
3
4
  const COLORS: Record<number, string> = { 1: '#22c55e', 2: '#f97316', 3: '#ef4444' };
4
5
  const MIX = ['#facc15', '#a78bfa', '#22d3ee', '#f472b6'];
5
6
  const ANIMS = ['split', 'boom', 'spin', 'stretch', 'drop'];
@@ -25,10 +26,8 @@ function addDot(container: HTMLElement, cx: number, cy: number, color: string) {
25
26
  container.appendChild(d); setTimeout(() => d.remove(), 1200);
26
27
  }
27
28
 
28
- function burst(container: HTMLElement | null, val: number, e?: PointerEvent) {
29
+ function burst(container: HTMLElement | null, val: number, cx: number, cy: number) {
29
30
  if (!container) return;
30
- let cx = 50, cy = 50;
31
- if (e) { const r = container.getBoundingClientRect(); cx = ((e.clientX - r.left) / r.width) * 100; cy = ((e.clientY - r.top) / r.height) * 100; }
32
31
  const color = COLORS[val] ?? '#fff';
33
32
  const count = 6 + val * 2;
34
33
  for (let i = 0; i < count; i++) {
@@ -72,7 +71,7 @@ class BasketGame {
72
71
  private setText(el: HTMLElement | null, v: string) { if (el) el.innerText = v; }
73
72
 
74
73
  private re(el: HTMLElement | null, cls: string) {
75
- if (!el) return; el.classList.remove(cls); void el.offsetWidth; el.classList.add(cls);
74
+ if (!el) return; el.classList.remove(cls); requestAnimationFrame(() => el.classList.add(cls));
76
75
  }
77
76
 
78
77
  private bind() {
@@ -116,10 +115,17 @@ class BasketGame {
116
115
  }
117
116
 
118
117
  addScore(team: 'A' | 'B', val: number, e?: Event) {
119
- this.score(team, val); this.streak(team, val);
120
118
  const isA = team === 'A';
121
- this.flip(isA ? this.u.sA : this.u.sB, isA ? this.u.stA : this.u.stB, String(this.sVal(team)));
122
- burst(isA ? this.u.pA : this.u.pB, val, e as PointerEvent);
119
+ const particlesContainer = isA ? this.u.pA : this.u.pB;
120
+ let cx = 50, cy = 50;
121
+ if (e && particlesContainer) {
122
+ const r = _bcr(particlesContainer);
123
+ cx = ((e.clientX - r.left) / r.width) * 100;
124
+ cy = ((e.clientY - r.top) / r.height) * 100;
125
+ }
126
+ this.score(team, val); this.streak(team, val);
127
+ this.flip(isA ? this.u.sA : this.u.stA, isA ? this.u.stA : this.u.stB, String(this.sVal(team)));
128
+ burst(particlesContainer, val, cx, cy);
123
129
  if (val >= 3) { this.re(isA ? this.u.tA : this.u.tB, 'bk-shake-card'); }
124
130
  this.screenFlash(val); this.vibe(val); this.render();
125
131
  }
@@ -130,13 +130,13 @@ const t = (ui ?? {}) as BeachVolleyballUI;
130
130
  <div class="tn-lineup-grid">
131
131
  <div class="tn-lineup-team-section">
132
132
  <span class="tn-team-name-hdr">{t.teamA}</span>
133
- <input type="text" class="tn-beach-input" id="tn-name-a1" placeholder={t.player1} />
134
- <input type="text" class="tn-beach-input" id="tn-name-a2" placeholder={t.player2} />
133
+ <input type="text" class="tn-beach-input" id="tn-name-a1" placeholder={t.player1} aria-label={`${t.teamA} ${t.player1}`} />
134
+ <input type="text" class="tn-beach-input" id="tn-name-a2" placeholder={t.player2} aria-label={`${t.teamA} ${t.player2}`} />
135
135
  </div>
136
136
  <div class="tn-lineup-team-section">
137
137
  <span class="tn-team-name-hdr">{t.teamB}</span>
138
- <input type="text" class="tn-beach-input" id="tn-name-b1" placeholder={t.player1} />
139
- <input type="text" class="tn-beach-input" id="tn-name-b2" placeholder={t.player2} />
138
+ <input type="text" class="tn-beach-input" id="tn-name-b1" placeholder={t.player1} aria-label={`${t.teamB} ${t.player1}`} />
139
+ <input type="text" class="tn-beach-input" id="tn-name-b2" placeholder={t.player2} aria-label={`${t.teamB} ${t.player2}`} />
140
140
  </div>
141
141
  </div>
142
142
  <button class="tn-btn-control" id="tn-close-lineup-btn">Done</button>
@@ -48,8 +48,7 @@ function triggerUndoAnimation(): void {
48
48
  const card = el('tn-card');
49
49
  if (card) {
50
50
  card.classList.remove('tn-undo-anim');
51
- void card.offsetWidth;
52
- card.classList.add('tn-undo-anim');
51
+ requestAnimationFrame(() => card.classList.add('tn-undo-anim'));
53
52
  setTimeout(() => card.classList.remove('tn-undo-anim'), 300);
54
53
  }
55
54
  }
@@ -138,11 +137,9 @@ function handleBaseClicks(e: MouseEvent, ctrl: BeachController): void {
138
137
  const courtHalfB = target.closest('#tn-court-half-b');
139
138
 
140
139
  if (courtHalfA) {
141
- const team = ctrl.ctx.state.swappedCourt ? 'b' : 'a';
142
- ctrl.addPoint(team, e.clientX, e.clientY);
140
+ ctrl.addPoint('a', e.clientX, e.clientY);
143
141
  } else if (courtHalfB) {
144
- const team = ctrl.ctx.state.swappedCourt ? 'a' : 'b';
145
- ctrl.addPoint(team, e.clientX, e.clientY);
142
+ ctrl.addPoint('b', e.clientX, e.clientY);
146
143
  }
147
144
  }
148
145
 
@@ -161,6 +158,7 @@ function bindInteractions(ctrl: BeachController): void {
161
158
  document.addEventListener('click', (e) => {
162
159
  const target = e.target as HTMLElement;
163
160
  handleBaseClicks(e, ctrl);
161
+ if (target.closest('#tn-court-half-a') || target.closest('#tn-court-half-b')) return;
164
162
  handleActionClicks(target, ctrl);
165
163
  });
166
164
  }
@@ -25,41 +25,42 @@ export function releaseWakeLock(): void {
25
25
  }
26
26
  }
27
27
 
28
+ export function spawnSandSplash(x: number, y: number): void {
29
+ const container = el('tn-particle-container');
30
+ if (!container) return;
31
+ requestAnimationFrame(() => {
32
+ const rect = container.getBoundingClientRect();
33
+ const relX = x - rect.left;
34
+ const relY = y - rect.top;
35
+
36
+ const colors = ['#f4d06f', '#f7d684', '#e9c46a', '#e0b54d', '#f8dd95'];
37
+ for (let i = 0; i < 12; i++) {
38
+ const dot = document.createElement('div');
39
+ dot.className = 'tn-sand-particle';
40
+ dot.style.left = `${relX}px`;
41
+ dot.style.top = `${relY}px`;
42
+ dot.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
43
+ const angle = Math.random() * Math.PI * 2;
44
+ const distance = 20 + Math.random() * 40;
45
+ const tx = Math.cos(angle) * distance;
46
+ const ty = -30 - Math.random() * 50;
47
+ dot.style.setProperty('--tx', `${tx}px`);
48
+ dot.style.setProperty('--ty', `${ty}px`);
49
+ container.appendChild(dot);
50
+ setTimeout(() => dot.remove(), 800);
51
+ }
52
+ });
53
+ }
54
+
28
55
  export function triggerScreenShake(): void {
29
56
  const card = el('tn-card');
30
57
  if (card) {
31
58
  card.classList.remove('tn-shaking');
32
- void card.offsetWidth;
33
- card.classList.add('tn-shaking');
59
+ requestAnimationFrame(() => card.classList.add('tn-shaking'));
34
60
  setTimeout(() => card.classList.remove('tn-shaking'), 300);
35
61
  }
36
62
  }
37
63
 
38
- export function spawnSandSplash(x: number, y: number): void {
39
- const container = el('tn-particle-container');
40
- if (!container) return;
41
- const rect = container.getBoundingClientRect();
42
- const relX = x - rect.left;
43
- const relY = y - rect.top;
44
-
45
- const colors = ['#f4d06f', '#f7d684', '#e9c46a', '#e0b54d', '#f8dd95'];
46
- for (let i = 0; i < 12; i++) {
47
- const dot = document.createElement('div');
48
- dot.className = 'tn-sand-particle';
49
- dot.style.left = `${relX}px`;
50
- dot.style.top = `${relY}px`;
51
- dot.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
52
- const angle = Math.random() * Math.PI * 2;
53
- const distance = 20 + Math.random() * 40;
54
- const tx = Math.cos(angle) * distance;
55
- const ty = -30 - Math.random() * 50;
56
- dot.style.setProperty('--tx', `${tx}px`);
57
- dot.style.setProperty('--ty', `${ty}px`);
58
- container.appendChild(dot);
59
- setTimeout(() => dot.remove(), 800);
60
- }
61
- }
62
-
63
64
  function updateSingleTeamServer(isA: boolean, team: 'a' | 'b', server: number): void {
64
65
  const activeTeam = isA === (team === 'a');
65
66
  const s1 = el(`tn-serve-${team}1`);
@@ -104,8 +105,7 @@ function renderScores(state: BeachVolleyballState): void {
104
105
  scoreA.textContent = state.teamA.score.toString();
105
106
  if (state.teamA.score !== lastScoreA) {
106
107
  scoreA.classList.remove('tn-pop-anim');
107
- void scoreA.offsetWidth;
108
- scoreA.classList.add('tn-pop-anim');
108
+ requestAnimationFrame(() => scoreA.classList.add('tn-pop-anim'));
109
109
  setTimeout(() => scoreA.classList.remove('tn-pop-anim'), 300);
110
110
  triggerScreenShake();
111
111
  }
@@ -114,8 +114,7 @@ function renderScores(state: BeachVolleyballState): void {
114
114
  scoreB.textContent = state.teamB.score.toString();
115
115
  if (state.teamB.score !== lastScoreB) {
116
116
  scoreB.classList.remove('tn-pop-anim');
117
- void scoreB.offsetWidth;
118
- scoreB.classList.add('tn-pop-anim');
117
+ requestAnimationFrame(() => scoreB.classList.add('tn-pop-anim'));
119
118
  setTimeout(() => scoreB.classList.remove('tn-pop-anim'), 300);
120
119
  triggerScreenShake();
121
120
  }
@@ -39,7 +39,7 @@ const t = (ui ?? {}) as DartsScoreKeeperUI;
39
39
  <div class="tn-scoreboard">
40
40
  <div class="tn-player-card tn-player-card-active" id="tn-card-a">
41
41
  <div class="tn-player-name-section">
42
- <input type="text" value={t.playerA} class="tn-player-input" id="tn-name-a" />
42
+ <input type="text" value={t.playerA} class="tn-player-input" id="tn-name-a" aria-label={t.playerA} />
43
43
  </div>
44
44
  <div class="tn-big-score" id="tn-score-a">501</div>
45
45
  <div class="tn-player-stats">
@@ -54,7 +54,7 @@ const t = (ui ?? {}) as DartsScoreKeeperUI;
54
54
 
55
55
  <div class="tn-player-card" id="tn-card-b">
56
56
  <div class="tn-player-name-section">
57
- <input type="text" value={t.playerB} class="tn-player-input" id="tn-name-b" />
57
+ <input type="text" value={t.playerB} class="tn-player-input" id="tn-name-b" aria-label={t.playerB} />
58
58
  </div>
59
59
  <div class="tn-big-score" id="tn-score-b">501</div>
60
60
  <div class="tn-player-stats">
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'The Elo Rating System and its Applications',
6
+ url: 'https://www.researchgate.net/publication/338235833_The_Elo_Rating_System_and_its_Applications',
7
+ },
8
+ {
9
+ name: 'US Chess Ratings and the K Factor',
10
+ url: 'https://new.uschess.org/news/rating-system',
11
+ },
12
+ {
13
+ name: 'FIDE Rating Regulations',
14
+ url: 'https://handbook.fide.com/chapter/B022024',
15
+ },
16
+ ];