@jjlmoya/utils-sports 1.59.0 → 1.61.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 (33) hide show
  1. package/package.json +3 -2
  2. package/src/tests/mfe_assets_contract.test.ts +48 -0
  3. package/src/tests/qa-test-helpers.ts +32 -0
  4. package/src/tests/qa_bibliography_links.test.ts +50 -0
  5. package/src/tests/qa_claim_evidence.test.ts +69 -0
  6. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  7. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  8. package/src/tool/rugbyScoreKeeper/component.astro +48 -35
  9. package/src/tool/rugbyScoreKeeper/i18n/de.ts +6 -0
  10. package/src/tool/rugbyScoreKeeper/i18n/en.ts +6 -0
  11. package/src/tool/rugbyScoreKeeper/i18n/es.ts +6 -0
  12. package/src/tool/rugbyScoreKeeper/i18n/fr.ts +6 -0
  13. package/src/tool/rugbyScoreKeeper/i18n/id.ts +6 -0
  14. package/src/tool/rugbyScoreKeeper/i18n/it.ts +6 -0
  15. package/src/tool/rugbyScoreKeeper/i18n/ja.ts +6 -0
  16. package/src/tool/rugbyScoreKeeper/i18n/ko.ts +6 -0
  17. package/src/tool/rugbyScoreKeeper/i18n/nl.ts +6 -0
  18. package/src/tool/rugbyScoreKeeper/i18n/pl.ts +6 -0
  19. package/src/tool/rugbyScoreKeeper/i18n/pt.ts +6 -0
  20. package/src/tool/rugbyScoreKeeper/i18n/ru.ts +14 -8
  21. package/src/tool/rugbyScoreKeeper/i18n/sv.ts +6 -0
  22. package/src/tool/rugbyScoreKeeper/i18n/tr.ts +6 -0
  23. package/src/tool/rugbyScoreKeeper/i18n/zh.ts +6 -0
  24. package/src/tool/rugbyScoreKeeper/logic.test.ts +113 -0
  25. package/src/tool/rugbyScoreKeeper/logic.ts +52 -29
  26. package/src/tool/rugbyScoreKeeper/rugby-scorekeeper.css +175 -27
  27. package/src/tool/rugbyScoreKeeper/storage.test.ts +66 -0
  28. package/src/tool/rugbyScoreKeeper/storage.ts +126 -0
  29. package/src/tool/rugbyScoreKeeper/ui-helpers.ts +14 -0
  30. package/src/tool/rugbyScoreKeeper/ui-render.ts +149 -0
  31. package/src/tool/rugbyScoreKeeper/ui-runtime.ts +260 -0
  32. package/src/tool/rugbyScoreKeeper/ui.ts +6 -0
  33. package/src/tool/rugbyScoreKeeper/ui-init.ts +0 -259
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.59.0",
3
+ "version": "1.61.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -27,7 +27,8 @@
27
27
  "lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
- "test": "vitest run --reporter=verbose --testTimeout=30000",
30
+ "test": "vitest run --exclude src/tests/qa_*.test.ts --reporter=verbose --testTimeout=30000",
31
+ "qa:audit": "vitest run src/tests/qa_*.test.ts --reporter=verbose",
31
32
  "preversion": "npm run lint && npm run test && npm run build",
32
33
  "postversion": "git push && git push --tags",
33
34
  "patch": "npm version patch",
@@ -0,0 +1,48 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { CATEGORY_OG_IMAGE, getUtilityOgImage } from '../mfe/assets';
6
+
7
+ const categoryImageMatch = CATEGORY_OG_IMAGE.match(
8
+ /^(\/_utilities\/[^/]+\/images)\/([^/]+\.webp)\?version=(.+)$/,
9
+ );
10
+
11
+ if (!categoryImageMatch) {
12
+ throw new Error(`Unexpected CATEGORY_OG_IMAGE format: ${CATEGORY_OG_IMAGE}`);
13
+ }
14
+
15
+ const [, imageUrlRoot, categoryImage, assetVersion] = categoryImageMatch;
16
+ const assetRoot = join(process.cwd(), 'public', imageUrlRoot.slice(1));
17
+ const categorySlug = basename(categoryImage, '.webp');
18
+
19
+ describe('MFE asset contract', () => {
20
+ it('has one non-empty English-slug OG image per category and registered tool', async () => {
21
+ const expectedSlugs = new Set([categorySlug]);
22
+
23
+ for (const { entry } of ALL_TOOLS) {
24
+ const englishLoader = entry.i18n.en;
25
+ if (!englishLoader) throw new Error(`Missing English locale for ${entry.id}`);
26
+
27
+ const englishContent = await englishLoader();
28
+ expectedSlugs.add(englishContent.slug);
29
+ }
30
+
31
+ const actualSlugs = new Set(
32
+ readdirSync(assetRoot)
33
+ .filter((filename) => filename.endsWith('.webp'))
34
+ .map((filename) => filename.slice(0, -'.webp'.length)),
35
+ );
36
+
37
+ expect(actualSlugs).toEqual(expectedSlugs);
38
+
39
+ for (const slug of expectedSlugs) {
40
+ const imagePath = join(assetRoot, `${slug}.webp`);
41
+ expect(existsSync(imagePath), `${imagePath} should exist`).toBe(true);
42
+ expect(statSync(imagePath).size, `${imagePath} should not be empty`).toBeGreaterThan(0);
43
+ expect(getUtilityOgImage(slug)).toBe(
44
+ `${imageUrlRoot}/${slug}.webp?version=${assetVersion}`,
45
+ );
46
+ }
47
+ }, 30000);
48
+ });
@@ -0,0 +1,32 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ export const repositoryRoot = process.cwd();
5
+ export const toolRoot = join(repositoryRoot, 'src', 'tool');
6
+
7
+ export function listToolDirectories(): string[] {
8
+ return readdirSync(toolRoot)
9
+ .map((name) => join(toolRoot, name))
10
+ .filter((path) => statSync(path).isDirectory());
11
+ }
12
+
13
+ export function findFiles(directory: string, extensions: string[]): string[] {
14
+ const files: string[] = [];
15
+
16
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
17
+ const path = join(directory, entry.name);
18
+ if (entry.isDirectory()) files.push(...findFiles(path, extensions));
19
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
20
+ }
21
+
22
+ return files;
23
+ }
24
+
25
+ export function read(path: string): string {
26
+ return readFileSync(path, 'utf8');
27
+ }
28
+
29
+ export function displayPath(path: string): string {
30
+ return relative(repositoryRoot, path).replace(/\\/g, '/');
31
+ }
32
+
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import type { ToolLocaleContent } from '../types';
4
+
5
+ interface LinkFailure {
6
+ tool: string;
7
+ message: string;
8
+ }
9
+
10
+ function validateUrl(tool: string, rawUrl: string, seen: Set<string>): LinkFailure[] {
11
+ let url: URL;
12
+ try {
13
+ url = new URL(rawUrl);
14
+ } catch {
15
+ return [{ tool, message: `invalid URL: ${rawUrl}` }];
16
+ }
17
+
18
+ const failures: LinkFailure[] = [];
19
+ if (url.protocol !== 'https:') failures.push({ tool, message: `non-HTTPS URL: ${rawUrl}` });
20
+ if (url.pathname === '/' && !url.search && !url.hash) {
21
+ failures.push({ tool, message: `generic homepage, cite the exact document: ${rawUrl}` });
22
+ }
23
+ if (seen.has(url.href)) failures.push({ tool, message: `duplicate source URL: ${rawUrl}` });
24
+ seen.add(url.href);
25
+ return failures;
26
+ }
27
+
28
+ describe('QA: bibliography links are specific and usable', () => {
29
+ it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
30
+ const failures: LinkFailure[] = [];
31
+
32
+ for (const tool of ALL_TOOLS) {
33
+ const loader = tool.entry.i18n.en;
34
+ if (!loader) {
35
+ failures.push({ tool: tool.entry.id, message: 'English locale loader is missing' });
36
+ continue;
37
+ }
38
+
39
+ const content = (await loader()) as ToolLocaleContent;
40
+ const seen = new Set<string>();
41
+
42
+ for (const entry of content.bibliography ?? []) {
43
+ failures.push(...validateUrl(tool.entry.id, entry.url, seen));
44
+ }
45
+ }
46
+
47
+ const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
48
+ expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
49
+ });
50
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { displayPath, listToolDirectories, read } from './qa-test-helpers';
5
+
6
+ const evidenceClaims = [
7
+ /\bofficial algorithm\b/i,
8
+ /\balgoritmo oficial\b/i,
9
+ /\b(?:records|data|rates|tables) updated(?:\s+(?:to|through)\s+20\d{2})?\b/i,
10
+ /\b(?:registros|datos|tasas|tablas) actualizad[oa]s?(?:\s+(?:a|hasta)\s+20\d{2})?\b/i,
11
+ /\b(?:validated|verified) (?:method|algorithm|calculation)\b/i,
12
+ /\b(?:método|algoritmo|cálculo) (?:validado|verificado)\b/i,
13
+ /\b(?:guarantees|ensures)\b/i,
14
+ /\bgarantiza\b/i,
15
+ /\bofficial (?:data|rate|calculation)\b/i,
16
+ /\b(?:datos|tasa|cálculo) oficial(?:es)?\b/i,
17
+ ];
18
+
19
+ const requiredEvidenceFields = [
20
+ 'reviewedAt',
21
+ 'methodology',
22
+ 'sources',
23
+ 'referenceCases',
24
+ 'limitations',
25
+ ];
26
+
27
+ describe('QA: strong factual claims are traceable', () => {
28
+ it('requires machine-readable validation evidence beside tools making strong claims', () => {
29
+ const failures: string[] = [];
30
+
31
+ for (const directory of listToolDirectories()) {
32
+ const localePaths = ['en', 'es']
33
+ .map((locale) => join(directory, 'i18n', `${locale}.ts`))
34
+ .filter(existsSync);
35
+ const claims = localePaths.flatMap((path) => {
36
+ const source = read(path);
37
+ return evidenceClaims
38
+ .filter((pattern) => pattern.test(source))
39
+ .map((pattern) => `${displayPath(path)} matches ${pattern.source}`);
40
+ });
41
+ if (claims.length === 0) continue;
42
+
43
+ const evidencePath = join(directory, 'validation.ts');
44
+ if (!existsSync(evidencePath)) {
45
+ failures.push(`${displayPath(evidencePath)} missing; claims: ${claims.join(' | ')}`);
46
+ continue;
47
+ }
48
+
49
+ const evidence = read(evidencePath);
50
+ const missingFields = requiredEvidenceFields.filter(
51
+ (field) => !new RegExp(`\\b${field}\\s*:`).test(evidence),
52
+ );
53
+ if (missingFields.length > 0) {
54
+ failures.push(`${displayPath(evidencePath)} missing fields: ${missingFields.join(', ')}`);
55
+ }
56
+ }
57
+
58
+ expect(
59
+ failures,
60
+ [
61
+ 'Claims such as official, validated, guaranteed or updated need inspectable evidence.',
62
+ 'Add validation.ts with reviewedAt, methodology, sources, referenceCases and limitations,',
63
+ 'or weaken/remove the unsupported claim.',
64
+ ...failures,
65
+ ].join('\n'),
66
+ ).toEqual([]);
67
+ });
68
+ });
69
+
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ function hasDedicatedLogicTest(toolDirectory: string): boolean {
13
+ const localCandidates = [
14
+ join(toolDirectory, 'logic.test.ts'),
15
+ join(toolDirectory, 'logic.spec.ts'),
16
+ join(toolDirectory, '__tests__', 'logic.test.ts'),
17
+ join(toolDirectory, '__tests__', 'logic.spec.ts'),
18
+ ];
19
+ if (localCandidates.some(existsSync)) return true;
20
+
21
+ const toolName = basename(toolDirectory);
22
+ const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
23
+ return centralTests.some((testPath) => {
24
+ const source = read(testPath).replace(/\\/g, '/');
25
+ return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
26
+ });
27
+ }
28
+
29
+ describe('QA: calculation logic has reference tests', () => {
30
+ it('gives every public calculator logic module its own behavioral test suite', () => {
31
+ const failures = listToolDirectories()
32
+ .filter((directory) => existsSync(join(directory, 'logic.ts')))
33
+ .filter((directory) => !hasDedicatedLogicTest(directory))
34
+ .map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
35
+
36
+ expect(
37
+ failures,
38
+ [
39
+ 'Add behavioral tests through each logic module public API.',
40
+ 'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
41
+ ...failures,
42
+ ].join('\n'),
43
+ ).toEqual([]);
44
+ });
45
+ });
46
+
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ interface VisibleLiteral {
13
+ line: number;
14
+ value: string;
15
+ }
16
+
17
+ const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
18
+ const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
19
+
20
+ function visibleWords(value: string): string {
21
+ return value
22
+ .replace(/\$\{[^}]*\}/g, ' ')
23
+ .replace(/<[^>]+>/g, ' ')
24
+ .replace(/&[a-z]+;/gi, ' ')
25
+ .replace(/[\d\p{P}\p{S}_]+/gu, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+
30
+ function isTechnicalLiteral(value: string): boolean {
31
+ return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
32
+ || /^data-[a-z0-9-]+$/i.test(value);
33
+ }
34
+
35
+ function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
36
+ const failures: VisibleLiteral[] = [];
37
+
38
+ source.split(/\r?\n/).forEach((line, index) => {
39
+ if (!domTextAssignment.test(line)) return;
40
+
41
+ const assignment = line.slice(line.search(domTextAssignment));
42
+ for (const match of assignment.matchAll(quotedLiteral)) {
43
+ const literal = match[2];
44
+ if (!literal || isTechnicalLiteral(literal)) continue;
45
+ const words = visibleWords(literal);
46
+ if (words && /\p{L}/u.test(words)) {
47
+ failures.push({ line: index + 1, value: literal });
48
+ }
49
+ }
50
+ });
51
+
52
+ return failures;
53
+ }
54
+
55
+ function runtimeSources(toolDirectory: string): string {
56
+ return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
57
+ .filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
58
+ .filter((path) => basename(path) !== 'ui.ts')
59
+ .map(read)
60
+ .join('\n');
61
+ }
62
+
63
+ describe('QA: runtime copy is localized', () => {
64
+ const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
65
+
66
+ it('does not write user-facing string literals directly into the DOM', () => {
67
+ const failures = componentFiles.flatMap((path) =>
68
+ hardcodedVisibleLiterals(read(path)).map(
69
+ ({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
70
+ ),
71
+ );
72
+
73
+ expect(
74
+ failures,
75
+ `Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
76
+ ).toEqual([]);
77
+ });
78
+
79
+ it('uses every string declared by each UI locale contract', () => {
80
+ const failures: string[] = [];
81
+
82
+ for (const directory of listToolDirectories()) {
83
+ const uiPath = join(directory, 'ui.ts');
84
+ if (!existsSync(uiPath)) continue;
85
+
86
+ const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
87
+ const runtime = runtimeSources(directory);
88
+ const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
89
+
90
+ if (unused.length > 0) {
91
+ failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
92
+ }
93
+ }
94
+
95
+ expect(
96
+ failures,
97
+ `Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
98
+ ).toEqual([]);
99
+ });
100
+ });
@@ -8,70 +8,82 @@ interface Props {
8
8
  }
9
9
 
10
10
  const { ui } = Astro.props;
11
- const t = (ui ?? {}) as RugbyScoreKeeperUI;
11
+ const t = (ui ?? {}) as unknown as RugbyScoreKeeperUI;
12
12
  ---
13
13
 
14
14
  <div class="rg-app" id="rg-app" data-rg-ui={JSON.stringify(t)}>
15
15
 
16
16
  <div class="rg-main-card">
17
17
 
18
+ <div class="rg-save-status" id="rg-save-status" role="status">
19
+ <span class="rg-save-dot" aria-hidden="true"></span>
20
+ <span id="rg-save-status-text">{t.savedLocally}</span>
21
+ </div>
22
+
18
23
  <div class="rg-strip">
19
- <div class="rg-team">
20
- <input type="text" class="rg-name" id="rg-name-home" value={t.home} aria-label={t.home} />
21
- <div class="rg-score" id="rg-score-home">0</div>
24
+ <div class="rg-team rg-team-home">
25
+ <input type="text" class="rg-name" id="rg-name-home" value={t.home} maxlength="40" aria-label={t.home} />
26
+ <div class="rg-score" id="rg-score-home" aria-live="polite">0</div>
22
27
  <div class="rg-bonus" id="rg-bonus-home"></div>
23
28
  </div>
24
29
  <div class="rg-divider">
25
30
  <span class="rg-vs">VS</span>
26
31
  <div class="rg-half-badge" id="rg-half-badge">{t.half1}</div>
27
32
  </div>
28
- <div class="rg-team">
29
- <input type="text" class="rg-name" id="rg-name-away" value={t.away} aria-label={t.away} />
30
- <div class="rg-score" id="rg-score-away">0</div>
33
+ <div class="rg-team rg-team-away">
34
+ <input type="text" class="rg-name" id="rg-name-away" value={t.away} maxlength="40" aria-label={t.away} />
35
+ <div class="rg-score" id="rg-score-away" aria-live="polite">0</div>
31
36
  <div class="rg-bonus" id="rg-bonus-away"></div>
32
37
  </div>
33
38
  </div>
34
39
 
35
40
  <div class="rg-columns">
36
41
  <div class="rg-col" data-team="home">
37
- <div class="rg-col-label">{t.home}</div>
38
- <button class="rg-btn rg-btn-try" data-action="try">+5 {t.tryLabel}</button>
39
- <button class="rg-btn rg-btn-conv" id="rg-conv-home" data-action="conv" disabled>+2 {t.conversion}</button>
40
- <button class="rg-btn rg-btn-pen" data-action="pen">+3 {t.penalty}</button>
41
- <button class="rg-btn rg-btn-drop" data-action="drop">+3 {t.dropGoal}</button>
42
+ <div class="rg-col-label" id="rg-col-label-home">{t.home}</div>
43
+ <button type="button" class="rg-btn rg-btn-try" data-action="try">{t.tryLabel}</button>
44
+ <button type="button" class="rg-btn rg-btn-conv" id="rg-conv-home" data-action="conv" disabled>{t.conversion}</button>
45
+ <button type="button" class="rg-btn rg-btn-pen" data-action="pen">{t.penalty}</button>
46
+ <button type="button" class="rg-btn rg-btn-drop" data-action="drop">{t.dropGoal}</button>
42
47
  </div>
43
48
  <div class="rg-col" data-team="away">
44
- <div class="rg-col-label">{t.away}</div>
45
- <button class="rg-btn rg-btn-try" data-action="try">+5 {t.tryLabel}</button>
46
- <button class="rg-btn rg-btn-conv" id="rg-conv-away" data-action="conv" disabled>+2 {t.conversion}</button>
47
- <button class="rg-btn rg-btn-pen" data-action="pen">+3 {t.penalty}</button>
48
- <button class="rg-btn rg-btn-drop" data-action="drop">+3 {t.dropGoal}</button>
49
+ <div class="rg-col-label" id="rg-col-label-away">{t.away}</div>
50
+ <button type="button" class="rg-btn rg-btn-try" data-action="try">{t.tryLabel}</button>
51
+ <button type="button" class="rg-btn rg-btn-conv" id="rg-conv-away" data-action="conv" disabled>{t.conversion}</button>
52
+ <button type="button" class="rg-btn rg-btn-pen" data-action="pen">{t.penalty}</button>
53
+ <button type="button" class="rg-btn rg-btn-drop" data-action="drop">{t.dropGoal}</button>
49
54
  </div>
50
55
  </div>
51
56
 
52
57
  <div class="rg-mid-row">
53
58
  <div class="rg-clock-panel">
59
+ <div class="rg-panel-header">{t.matchClock}</div>
54
60
  <div class="rg-clock-ring">
55
61
  <svg class="rg-clock-svg" viewBox="0 0 120 120">
56
62
  <circle class="rg-clock-bg" cx="60" cy="60" r="54" />
57
- <circle class="rg-clock-fill" id="rg-clock-fill" cx="60" cy="60" r="54" />
63
+ <circle class="rg-clock-fill" id="rg-clock-fill" cx="60" cy="60" r="54" aria-hidden="true" />
58
64
  </svg>
59
65
  <div class="rg-clock-inner">
60
- <div class="rg-clock-time" id="rg-clock-time">00:00</div>
66
+ <div class="rg-clock-time" id="rg-clock-time" aria-live="polite">00:00</div>
61
67
  <div class="rg-clock-label">{t.half} <span id="rg-half-label">1</span></div>
62
68
  </div>
63
69
  </div>
64
- <button class="rg-btn rg-btn-clock" id="rg-btn-clock">{t.startMatch}</button>
70
+ <button type="button" class="rg-btn rg-btn-clock" id="rg-btn-clock">{t.startMatch}</button>
65
71
  </div>
66
72
  <div class="rg-sinbin-panel">
67
- <div class="rg-panel-header">{t.sinBin}</div>
73
+ <div class="rg-panel-header">{t.sinBinTitle}</div>
68
74
  <div class="rg-sinbin-form">
69
- <input type="text" class="rg-input" id="rg-sinbin-input" placeholder={t.sinBinPlayer} aria-label={t.sinBinPlayer} />
70
- <select class="rg-select" id="rg-sinbin-duration" aria-label={t.sinBin + " duration"}>
71
- <option value="600">10 min</option>
72
- <option value="300">5 min</option>
73
- </select>
74
- <button class="rg-btn rg-btn-yellow" id="rg-btn-sinbin">{t.sinBinAdd}</button>
75
+ <input type="text" class="rg-input" id="rg-sinbin-input" maxlength="80" placeholder={t.sinBinPlayer} aria-label={t.sinBinPlayer} />
76
+ <div class="rg-choice-row">
77
+ <div class="rg-choice-group" role="group" aria-label={t.teamLabel}>
78
+ <button type="button" class="rg-choice rg-choice-home is-active" data-sinbin-team="home" aria-pressed="true">{t.home}</button>
79
+ <button type="button" class="rg-choice rg-choice-away" data-sinbin-team="away" aria-pressed="false">{t.away}</button>
80
+ </div>
81
+ <div class="rg-choice-group" role="group" aria-label={t.durationLabel}>
82
+ <button type="button" class="rg-choice is-active" data-sinbin-duration="600" aria-pressed="true">10 {t.minutesShort}</button>
83
+ <button type="button" class="rg-choice" data-sinbin-duration="300" aria-pressed="false">5 {t.minutesShort}</button>
84
+ </div>
85
+ </div>
86
+ <button type="button" class="rg-btn rg-btn-yellow" id="rg-btn-sinbin">{t.sinBinAdd}</button>
75
87
  </div>
76
88
  <div class="rg-sinbin-list" id="rg-sinbin-list">
77
89
  <div class="rg-sinbin-empty">{t.sinBinEmpty}</div>
@@ -85,13 +97,13 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
85
97
  <div class="rg-history-list" id="rg-history-list">
86
98
  <div class="rg-history-empty">{t.eventEmpty}</div>
87
99
  </div>
88
- <button class="rg-btn rg-btn-undo" id="rg-btn-undo" disabled>{t.undoBtn}</button>
100
+ <button type="button" class="rg-btn rg-btn-undo" id="rg-btn-undo" disabled>{t.undoBtn}</button>
89
101
  </div>
90
102
  <div class="rg-summary">
91
103
  <div class="rg-panel-header">{t.scoringSummary}</div>
92
104
  <table class="rg-table">
93
105
  <thead>
94
- <tr><th></th><th>{t.home}</th><th>{t.away}</th></tr>
106
+ <tr><th></th><th class="rg-th-home" id="rg-summary-home">{t.home}</th><th class="rg-th-away" id="rg-summary-away">{t.away}</th></tr>
95
107
  </thead>
96
108
  <tbody id="rg-summary-body">
97
109
  <tr><td>{t.tryScored}</td><td>0</td><td>0</td></tr>
@@ -110,28 +122,29 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
110
122
  </div>
111
123
  </div>
112
124
 
113
- <div id="rg-banner" class="rg-banner" style="display:none">
125
+ <div id="rg-banner" class="rg-banner" role="status" aria-live="polite" style="display:none">
114
126
  <div class="rg-banner-text" id="rg-banner-text"></div>
115
127
  </div>
116
128
 
117
- <div class="rg-reset-modal" id="rg-modal">
129
+ <div class="rg-reset-modal" id="rg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="rg-modal-title">
118
130
  <div class="rg-modal-content">
131
+ <h2 class="rg-modal-title" id="rg-modal-title">{t.resetMatch}</h2>
119
132
  <div class="rg-modal-text">{t.resetConfirm}</div>
120
133
  <div class="rg-modal-btns">
121
- <button class="rg-btn rg-btn-modal-cancel" id="rg-modal-cancel">{t.cancel}</button>
122
- <button class="rg-btn rg-btn-modal-confirm" id="rg-modal-confirm">{t.confirm}</button>
134
+ <button type="button" class="rg-btn rg-btn-modal-cancel" id="rg-modal-cancel">{t.cancel}</button>
135
+ <button type="button" class="rg-btn rg-btn-modal-confirm" id="rg-modal-confirm">{t.confirm}</button>
123
136
  </div>
124
137
  </div>
125
138
  </div>
126
139
 
127
140
  <div class="rg-actions">
128
- <button class="rg-btn rg-btn-reset" id="rg-btn-reset">{t.resetMatch}</button>
141
+ <button type="button" class="rg-btn rg-btn-reset" id="rg-btn-reset">{t.resetMatch}</button>
129
142
  </div>
130
143
 
131
144
  </div>
132
145
  </div>
133
146
 
134
147
  <script>
135
- import { initRugbyScorekeeper } from './ui-init';
148
+ import { initRugbyScorekeeper } from './ui-runtime';
136
149
  initRugbyScorekeeper();
137
150
  </script>
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Letztes Rückgängig',
215
215
  timeOff: 'Zeit Gestoppt',
216
216
  timeOn: 'Zeit Läuft',
217
+ fullTime: 'Spielende',
218
+ minutesShort: 'Min.',
219
+ savedLocally: 'Auf diesem Gerät gespeichert',
220
+ saveUnavailable: 'Speichern nicht verfügbar',
221
+ teamLabel: 'Team',
222
+ durationLabel: 'Dauer',
217
223
  },
218
224
  };
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Undo Last',
215
215
  timeOff: 'Time Off',
216
216
  timeOn: 'Time On',
217
+ fullTime: 'Full time',
218
+ minutesShort: 'min',
219
+ savedLocally: 'Saved on this device',
220
+ saveUnavailable: 'Saving unavailable',
221
+ teamLabel: 'Team',
222
+ durationLabel: 'Duration',
217
223
  },
218
224
  };
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Deshacer Último',
215
215
  timeOff: 'Tiempo Detenido',
216
216
  timeOn: 'Tiempo en Marcha',
217
+ fullTime: 'Fin del partido',
218
+ minutesShort: 'min',
219
+ savedLocally: 'Guardado en este dispositivo',
220
+ saveUnavailable: 'No se puede guardar',
221
+ teamLabel: 'Equipo',
222
+ durationLabel: 'Duración',
217
223
  },
218
224
  };
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Annuler le Dernier',
215
215
  timeOff: 'Temps Arrêté',
216
216
  timeOn: 'Temps en Marche',
217
+ fullTime: 'Fin du match',
218
+ minutesShort: 'min',
219
+ savedLocally: 'Enregistré sur cet appareil',
220
+ saveUnavailable: 'Enregistrement indisponible',
221
+ teamLabel: 'Équipe',
222
+ durationLabel: 'Durée',
217
223
  },
218
224
  };
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Batalkan terakhir',
215
215
  timeOff: 'Waktu Berhenti',
216
216
  timeOn: 'Waktu Berjalan',
217
+ fullTime: 'Pertandingan Selesai',
218
+ minutesShort: 'mnt',
219
+ savedLocally: 'Tersimpan di perangkat ini',
220
+ saveUnavailable: 'Penyimpanan tidak tersedia',
221
+ teamLabel: 'Tim',
222
+ durationLabel: 'Durasi',
217
223
  },
218
224
  };
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Annulla Ultimo',
215
215
  timeOff: 'Tempo Fermo',
216
216
  timeOn: 'Tempo in Gioco',
217
+ fullTime: 'Fine partita',
218
+ minutesShort: 'min',
219
+ savedLocally: 'Salvato su questo dispositivo',
220
+ saveUnavailable: 'Salvataggio non disponibile',
221
+ teamLabel: 'Squadra',
222
+ durationLabel: 'Durata',
217
223
  },
218
224
  };
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: '最後を元に戻す',
215
215
  timeOff: 'タイムオフ',
216
216
  timeOn: 'タイムオン',
217
+ fullTime: '試合終了',
218
+ minutesShort: '分',
219
+ savedLocally: 'この端末に保存済み',
220
+ saveUnavailable: '保存できません',
221
+ teamLabel: 'チーム',
222
+ durationLabel: '時間',
217
223
  },
218
224
  };
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: '마지막 실행 취소',
215
215
  timeOff: '타임 오프',
216
216
  timeOn: '타임 온',
217
+ fullTime: '경기 종료',
218
+ minutesShort: '분',
219
+ savedLocally: '이 기기에 저장됨',
220
+ saveUnavailable: '저장할 수 없음',
221
+ teamLabel: '팀',
222
+ durationLabel: '시간',
217
223
  },
218
224
  };
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Ongedaan Maken Laatste',
215
215
  timeOff: 'Tijd Stil',
216
216
  timeOn: 'Tijd Loopt',
217
+ fullTime: 'Einde wedstrijd',
218
+ minutesShort: 'min',
219
+ savedLocally: 'Op dit apparaat opgeslagen',
220
+ saveUnavailable: 'Opslaan niet beschikbaar',
221
+ teamLabel: 'Team',
222
+ durationLabel: 'Duur',
217
223
  },
218
224
  };