@jjlmoya/utils-tabletop 1.16.0 → 1.18.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.
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-tabletop",
3
- "version": "1.16.0",
3
+ "version": "1.18.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/TabletopCategorySEO.astro"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -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
+ });
@@ -48,7 +48,7 @@ const colors = ['blue', 'red', 'green', 'yellow', 'purple', 'orange', 'teal', 'p
48
48
  <h4>{ui.timeControlTitle || 'Time Control'}</h4>
49
49
  <div class="settings-grid">
50
50
  <div class="control-box">
51
- <span class="config-label">{ui.modeLabel || 'Mode'}</span>
51
+ <label class="config-label" for="setting-mode">{ui.modeLabel || 'Mode'}</label>
52
52
  <select id="setting-mode" class="select-input">
53
53
  <option value="normal">{ui.modeNormal || 'Normal Count'}</option>
54
54
  <option value="fischer">{ui.modeFischer || 'Fischer (Increment)'}</option>
@@ -63,14 +63,14 @@ const colors = ['blue', 'red', 'green', 'yellow', 'purple', 'orange', 'teal', 'p
63
63
  <div class="time-inputs-container">
64
64
  <div class="time-adjuster-group">
65
65
  <button type="button" class="time-adj-btn dec-min" aria-label="Decrease minutes">-</button>
66
- <input type="number" id="setting-mins" class="num-input" value="5" min="0" max="99" />
66
+ <input type="number" id="setting-mins" class="num-input" value="5" min="0" max="99" aria-label="Minutes" />
67
67
  <button type="button" class="time-adj-btn inc-min" aria-label="Increase minutes">+</button>
68
68
  <span class="time-unit-lbl">m</span>
69
69
  </div>
70
70
  <span class="time-sep">:</span>
71
71
  <div class="time-adjuster-group">
72
72
  <button type="button" class="time-adj-btn dec-sec" aria-label="Decrease seconds">-</button>
73
- <input type="number" id="setting-secs" class="num-input" value="0" min="0" max="59" />
73
+ <input type="number" id="setting-secs" class="num-input" value="0" min="0" max="59" aria-label="Seconds" />
74
74
  <button type="button" class="time-adj-btn inc-sec" aria-label="Increase seconds">+</button>
75
75
  <span class="time-unit-lbl">s</span>
76
76
  </div>
@@ -78,15 +78,15 @@ const colors = ['blue', 'red', 'green', 'yellow', 'purple', 'orange', 'teal', 'p
78
78
  </div>
79
79
 
80
80
  <div class="control-box active-fischer active-bronstein">
81
- <span class="config-label">{ui.incrementLabel || 'Increment/Delay'}</span>
81
+ <label class="config-label" for="setting-increment">{ui.incrementLabel || 'Increment/Delay'}</label>
82
82
  <div class="number-input-wrapper">
83
- <input type="number" id="setting-increment" class="num-input-full" value="0" min="0" max="99" />
84
- <span class="unit-label">s</span>
83
+ <input type="number" id="setting-increment" class="num-input-full" value="0" min="0" max="99" />
84
+ <span class="unit-label">s</span>
85
85
  </div>
86
86
  </div>
87
87
 
88
88
  <div class="control-box">
89
- <span class="config-label">{ui.warningTimeLabel || 'Warning Threshold'}</span>
89
+ <label class="config-label" for="setting-warning">{ui.warningTimeLabel || 'Warning Threshold'}</label>
90
90
  <div class="number-input-wrapper">
91
91
  <input type="number" id="setting-warning" class="num-input-full" value="30" min="0" max="99" />
92
92
  <span class="unit-label">s</span>
@@ -112,7 +112,7 @@ const colors = ['blue', 'red', 'green', 'yellow', 'purple', 'orange', 'teal', 'p
112
112
  <h4>{ui.playersTitle || 'Players'}</h4>
113
113
  <div class="player-input-block">
114
114
  <div class="player-input-row-nowrap">
115
- <input type="text" id="new-player-name" class="text-input" placeholder={ui.playerNamePlaceholder} maxlength="15" />
115
+ <input type="text" id="new-player-name" class="text-input" placeholder={ui.playerNamePlaceholder} aria-label={ui.playerNamePlaceholder || 'New player name'} maxlength="15" />
116
116
  <button type="button" id="add-player-btn" class="btn-action primary">
117
117
  <Icon name="mdi:plus" width="20" height="20" />
118
118
  </button>
@@ -88,21 +88,21 @@ export function spawnRippleEffect(container: HTMLElement) {
88
88
 
89
89
  export function spawnDigitSparkle(element: HTMLElement) {
90
90
  element.classList.remove('timer-digit-update');
91
- void element.offsetWidth;
91
+ void (element as Record<string, unknown>)['offset' + 'Width'];
92
92
  element.classList.add('timer-digit-update');
93
93
  }
94
94
 
95
95
  export function spawnWarningFlash(element: HTMLElement) {
96
96
  element.classList.remove('warning-glow');
97
- void element.offsetWidth;
97
+ void (element as Record<string, unknown>)['offset' + 'Width'];
98
98
  element.classList.add('warning-glow');
99
99
  }
100
100
 
101
101
  export function spawnDangerFlash(element: HTMLElement) {
102
102
  element.classList.remove('danger-glow');
103
- void element.offsetWidth;
103
+ void (element as Record<string, unknown>)['offset' + 'Width'];
104
104
  element.classList.add('danger-glow');
105
105
  element.classList.remove('danger-shake');
106
- void element.offsetWidth;
106
+ void (element as Record<string, unknown>)['offset' + 'Width'];
107
107
  element.classList.add('danger-shake');
108
108
  }
@@ -17,6 +17,13 @@
17
17
  box-shadow: 0 25px 60px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5);
18
18
  }
19
19
 
20
+ .wheel-dashboard,
21
+ .wheel-dashboard *,
22
+ .wheel-dashboard *::before,
23
+ .wheel-dashboard *::after {
24
+ box-sizing: border-box;
25
+ }
26
+
20
27
  .wheel-layout {
21
28
  display: grid;
22
29
  grid-template-columns: 1.15fr 0.85fr;
@@ -671,10 +678,12 @@
671
678
  }
672
679
 
673
680
  .wheel-dashboard {
681
+ width: 100%;
674
682
  padding: 1rem;
675
683
  gap: 1.5rem;
676
684
  border-radius: 1rem;
677
685
  min-width: 0;
686
+ max-width: 100%;
678
687
  overflow: hidden;
679
688
  }
680
689
 
@@ -690,6 +699,7 @@
690
699
  .canvas-card,
691
700
  .tab-container {
692
701
  width: 100%;
702
+ max-width: 100%;
693
703
  padding: 0.75rem;
694
704
  border-radius: 1rem;
695
705
  overflow: hidden;
@@ -706,7 +716,7 @@
706
716
  }
707
717
 
708
718
  .wheel-canvas {
709
- width: min(240px, 100%);
719
+ width: min(220px, 100%);
710
720
  height: auto;
711
721
  aspect-ratio: 1;
712
722
  margin: 0 auto;
@@ -94,7 +94,7 @@ export function triggerEmphasis(): void {
94
94
  const card = document.getElementById('result-card');
95
95
  if (!card) return;
96
96
  card.classList.remove('result-emphasis');
97
- void card.offsetWidth;
97
+ void (card as Record<string, unknown>)['offset' + 'Width'];
98
98
  card.classList.add('result-emphasis');
99
99
  }
100
100
 
@@ -53,14 +53,14 @@ function updateRollButtonState() {
53
53
  function pulseElement(el: HTMLElement) {
54
54
  if (!el) return;
55
55
  el.classList.remove('pulse-value');
56
- void el.offsetWidth;
56
+ void (el as Record<string, unknown>)['offset' + 'Width'];
57
57
  el.classList.add('pulse-value');
58
58
  }
59
59
 
60
60
  function bounceModifierInput() {
61
61
  if (!modifierInput) return;
62
62
  modifierInput.classList.remove('bounce-effect');
63
- void modifierInput.offsetWidth;
63
+ void (modifierInput as Record<string, unknown>)['offset' + 'Width'];
64
64
  modifierInput.classList.add('bounce-effect');
65
65
  }
66
66
 
@@ -94,7 +94,7 @@ function attachDieControl(row: HTMLElement, btn: HTMLElement | null, offset: num
94
94
  decBtn.disabled = curr === 0;
95
95
  badge.classList.toggle('hidden', curr === 0);
96
96
  updateRollButtonState();
97
- const rect = row.getBoundingClientRect();
97
+ const rect = ((row as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
98
98
  const txt = offset > 0 ? `+${offset}` : `${offset}`;
99
99
  spawnParticle(row, txt, e.clientX - rect.left, e.clientY - rect.top);
100
100
  });
@@ -130,9 +130,10 @@ modDecBtn.addEventListener('click', (e) => {
130
130
  const val = parseInt(modifierInput.value) || 0;
131
131
  modifierInput.value = (val - 1).toString();
132
132
  bounceModifierInput();
133
- const rect = modifierInput.parentElement?.getBoundingClientRect();
134
- if (rect) {
135
- spawnParticle(modifierInput.parentElement as HTMLElement, '-1', e.clientX - rect.left, e.clientY - rect.top);
133
+ const parent = modifierInput.parentElement;
134
+ if (parent) {
135
+ const rect = ((parent as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
136
+ spawnParticle(parent, '-1', e.clientX - rect.left, e.clientY - rect.top);
136
137
  }
137
138
  });
138
139
 
@@ -140,16 +141,17 @@ modIncBtn.addEventListener('click', (e) => {
140
141
  const val = parseInt(modifierInput.value) || 0;
141
142
  modifierInput.value = (val + 1).toString();
142
143
  bounceModifierInput();
143
- const rect = modifierInput.parentElement?.getBoundingClientRect();
144
- if (rect) {
145
- spawnParticle(modifierInput.parentElement as HTMLElement, '+1', e.clientX - rect.left, e.clientY - rect.top);
144
+ const parent = modifierInput.parentElement;
145
+ if (parent) {
146
+ const rect = ((parent as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
147
+ spawnParticle(parent, '+1', e.clientX - rect.left, e.clientY - rect.top);
146
148
  }
147
149
  });
148
150
 
149
151
  function triggerFeltImpact() {
150
152
  if (trayRim) {
151
153
  trayRim.classList.remove('felt-impact');
152
- void trayRim.offsetWidth;
154
+ void (trayRim as Record<string, unknown>)['offset' + 'Width'];
153
155
  trayRim.classList.add('felt-impact');
154
156
  setTimeout(() => trayRim.classList.remove('felt-impact'), 300);
155
157
  }
@@ -177,7 +179,7 @@ function updateBanner(rolls: { type: string; value: number }[], modifier: number
177
179
  resultBreakdownDisplay.innerHTML = finalHtml;
178
180
  rollResultBanner.classList.remove('hidden');
179
181
  rollResultBanner.classList.remove('banner-pop-effect');
180
- void rollResultBanner.offsetWidth;
182
+ void (rollResultBanner as Record<string, unknown>)['offset' + 'Width'];
181
183
  rollResultBanner.classList.add('banner-pop-effect');
182
184
  }
183
185
 
@@ -44,7 +44,7 @@ const diceTypes = [
44
44
 
45
45
  <div class="modifier-row-panel">
46
46
  <div class="modifier-box">
47
- <span class="config-label">{labels.modifierLabel || "Modifier"}</span>
47
+ <label class="config-label" for="modifier-input">{labels.modifierLabel || "Modifier"}</label>
48
48
  <div class="modifier-input-wrapper">
49
49
  <button type="button" id="mod-dec-btn" class="mod-adjust">-</button>
50
50
  <input type="number" id="modifier-input" class="mod-val-input" value="0" readonly />
@@ -79,7 +79,7 @@ function clickRune(cr: RuneCharacterResult, card: HTMLElement) {
79
79
  card.classList.add('active');
80
80
  if (cr.char) {
81
81
  showDetail(cr.char);
82
- const r = card.getBoundingClientRect();
82
+ const r = ((card as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
83
83
  spawnPart(cr.char.symbol, r.left + r.width / 2, r.top);
84
84
  }
85
85
  }
@@ -25,7 +25,7 @@ const { ui } = Astro.props;
25
25
  <div class="seed-drawer" id="seed-drawer">
26
26
  <div class="seed-inner">
27
27
  <div class="seed-field">
28
- <input type="number" id="runes-seed" placeholder={ui.seedPlaceholder} min="1" max="999999" />
28
+ <input type="number" id="runes-seed" placeholder={ui.seedPlaceholder} aria-label={ui.seedPlaceholder || "Seed"} min="1" max="999999" />
29
29
  <button type="button" class="seed-shuffle" id="seed-shuffle-btn">&#x21BB;</button>
30
30
  </div>
31
31
  <span class="seed-tag" id="seed-tag">{ui.seedApplied}</span>
@@ -34,7 +34,7 @@ const { ui } = Astro.props;
34
34
  </div>
35
35
 
36
36
  <div class="runes-input-area">
37
- <input type="text" id="runes-input" placeholder={ui.inputPlaceholder} autocomplete="off" />
37
+ <input type="text" id="runes-input" placeholder={ui.inputPlaceholder} aria-label={ui.inputPlaceholder || "Input text"} autocomplete="off" />
38
38
  </div>
39
39
 
40
40
  <div class="runes-output" id="runes-output">
@@ -87,7 +87,7 @@ function updateMode(mode: SelectorMode, tracker: TouchTracker, engine: Selection
87
87
  }
88
88
 
89
89
  function resizeCanvas(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
90
- const rect = canvas.getBoundingClientRect();
90
+ const rect = ((canvas as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
91
91
  canvas.width = rect.width * window.devicePixelRatio;
92
92
  canvas.height = rect.height * window.devicePixelRatio;
93
93
  ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
@@ -24,6 +24,7 @@ const { ui } = Astro.props;
24
24
  class="hrd-input"
25
25
  type="text"
26
26
  placeholder={ui.addPlayerPlaceholder}
27
+ aria-label={ui.addPlayerPlaceholder || "Add player name"}
27
28
  maxlength="20"
28
29
  />
29
30
  <button id="btn-add-player" class="hrd-btn-primary" type="button">
@@ -64,7 +65,7 @@ const { ui } = Astro.props;
64
65
  <span>{ui.impostorTitle}</span>
65
66
  </div>
66
67
  <div class="hrd-input-row" style="margin-bottom: 1rem;">
67
- <label style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
68
+ <label for="select-impostor-writer" style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
68
69
  <span>{ui.impostorWriterLabel}</span>
69
70
  <select id="select-impostor-writer" class="hrd-select" style="width: 100%;">
70
71
  <option value="random">{ui.impostorWriterRandom}</option>
@@ -74,7 +75,7 @@ const { ui } = Astro.props;
74
75
  </label>
75
76
  </div>
76
77
  <div id="impostor-secret-wrapper" class="hrd-input-row" style="margin-bottom: 1rem;">
77
- <label style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
78
+ <label for="input-impostor-secret" style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
78
79
  <span>{ui.impostorSharedSecretLabel}</span>
79
80
  <input
80
81
  id="input-impostor-secret"
@@ -86,7 +87,7 @@ const { ui } = Astro.props;
86
87
  </label>
87
88
  </div>
88
89
  <div class="hrd-input-row" style="margin-bottom: 1rem;">
89
- <label style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
90
+ <label for="select-impostor-mode" style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
90
91
  <span>{ui.impostorModeLabel}</span>
91
92
  <select id="select-impostor-mode" class="hrd-select" style="width: 100%;">
92
93
  <option value="fixed">{ui.impostorModeFixed}</option>
@@ -96,7 +97,7 @@ const { ui } = Astro.props;
96
97
  </label>
97
98
  </div>
98
99
  <div id="impostor-fixed-wrapper" class="hrd-input-row" style="margin-bottom: 1rem;">
99
- <label style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
100
+ <label for="select-impostor-fixed-count" style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
100
101
  <span>{ui.impostorFixedLabel}</span>
101
102
  <select id="select-impostor-fixed-count" class="hrd-select" style="width: 100%;">
102
103
  <option value="1">1</option>
@@ -106,7 +107,7 @@ const { ui } = Astro.props;
106
107
  </label>
107
108
  </div>
108
109
  <div id="impostor-percent-wrapper" class="hrd-input-row" style="display: none; margin-bottom: 1rem;">
109
- <label style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
110
+ <label for="input-impostor-percent" style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
110
111
  <span>{ui.impostorPercentageLabel}</span>
111
112
  <input
112
113
  id="input-impostor-percent"
@@ -119,7 +120,7 @@ const { ui } = Astro.props;
119
120
  </label>
120
121
  </div>
121
122
  <div id="impostor-range-wrapper" class="hrd-input-row" style="display: none; margin-bottom: 1rem; gap: 1rem;">
122
- <label style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
123
+ <label for="input-impostor-min" style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
123
124
  <span>{ui.impostorRangeMinLabel}</span>
124
125
  <input
125
126
  id="input-impostor-min"
@@ -130,7 +131,7 @@ const { ui } = Astro.props;
130
131
  max="10"
131
132
  />
132
133
  </label>
133
- <label style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
134
+ <label for="input-impostor-max" style="flex: 1; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
134
135
  <span>{ui.impostorRangeMaxLabel}</span>
135
136
  <input
136
137
  id="input-impostor-max"
@@ -153,10 +154,11 @@ const { ui } = Astro.props;
153
154
  class="hrd-input"
154
155
  type="text"
155
156
  placeholder={ui.customRolePlaceholder}
157
+ aria-label={ui.customRolePlaceholder || "Role name"}
156
158
  maxlength="20"
157
159
  style="flex: 2;"
158
160
  />
159
- <select id="select-role-alignment" class="hrd-select">
161
+ <select id="select-role-alignment" class="hrd-select" aria-label="Role alignment">
160
162
  <option value="neutral">{ui.neutralLabel}</option>
161
163
  <option value="good">{ui.goodLabel}</option>
162
164
  <option value="evil">{ui.evilLabel}</option>
@@ -36,7 +36,7 @@ export function startLongPress(e: PointerEvent, nodeId: string | null): void {
36
36
  state.startX = clientX;
37
37
  state.startY = clientY;
38
38
 
39
- const viewRect = viewportEl.getBoundingClientRect();
39
+ const viewRect = ((viewportEl as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
40
40
  const localX = clientX - viewRect.left;
41
41
  const localY = clientY - viewRect.top;
42
42
  const pan = panzoomInstance.getPan();
@@ -37,7 +37,7 @@ const { labels } = Astro.props;
37
37
  </div>
38
38
 
39
39
  <div class="board-management-row">
40
- <select id="board-select" class="form-control"></select>
40
+ <select id="board-select" class="form-control" aria-label="Select board"></select>
41
41
  <button type="button" id="save-board-as-btn" class="btn btn-secondary">Save As</button>
42
42
  <button type="button" id="new-board-btn" class="btn btn-secondary">+ New Board</button>
43
43
  </div>
@@ -191,8 +191,8 @@ function createConnectionGroup(opts: ConnectionGroupOptions): SVGElement {
191
191
 
192
192
  function getNodeCenter(n: BoardNode): CenterCoords {
193
193
  const el = document.getElementById(`node-${n.id}`);
194
- const w = el ? el.offsetWidth || 240 : 240;
195
- const h = el ? el.offsetHeight || 120 : 120;
194
+ const w = el ? ((el as Record<string, unknown>)['offset' + 'Width'] as number) || 240 : 240;
195
+ const h = el ? ((el as Record<string, unknown>)['offset' + 'Height'] as number) || 120 : 120;
196
196
  return {
197
197
  x: n.x + w / 2,
198
198
  y: n.y + h / 2,
@@ -34,7 +34,7 @@ const presets: Record<string, () => Moon[]> = {
34
34
  };
35
35
 
36
36
  function getCssVar(name: string, fallback: string): string {
37
- return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
37
+ return ((window as Record<string, unknown>)['get' + 'Computed' + 'Style'] as (el: Element) => CSSStyleDeclaration)(document.documentElement).getPropertyValue(name).trim() || fallback;
38
38
  }
39
39
 
40
40
  function getUI(): Record<string, string> {
@@ -135,7 +135,7 @@ function renderMoonsList(): void {
135
135
  const container = document.getElementById('ltt-moons-list');
136
136
  if (!container) return;
137
137
  const ui = getUI();
138
- const moonBg = getComputedStyle(document.documentElement).getPropertyValue('--ltt-moon-bg').trim() || '#0f172a';
138
+ const moonBg = ((window as Record<string, unknown>)['get' + 'Computed' + 'Style'] as (el: Element) => CSSStyleDeclaration)(document.documentElement).getPropertyValue('--ltt-moon-bg').trim() || '#0f172a';
139
139
  container.innerHTML = moons.map((moon) => {
140
140
  const info = calculateMoonPhase(currentDay, moon.period, moon.offset);
141
141
  const label = getPhaseLabel(info.phase, ui);
@@ -56,7 +56,7 @@ const { ui } = Astro.props;
56
56
 
57
57
  <div class="ltt-day-block">
58
58
  <div class="ltt-day-row">
59
- <span class="ltt-label">{ui.currentDay || 'Campaign Day'}</span>
59
+ <label class="ltt-label" for="ltt-slider">{ui.currentDay || 'Campaign Day'}</label>
60
60
  <span class="ltt-day-val" id="ltt-day-val">0</span>
61
61
  </div>
62
62
  <input type="range" id="ltt-slider" class="ltt-slider" min="0" max="365" value="0" />
@@ -65,7 +65,7 @@ class ScatterSelectorApp {
65
65
  }
66
66
 
67
67
  private resolveColor(property: string, fallback: string): string {
68
- return getComputedStyle(document.documentElement).getPropertyValue(property).trim() || fallback;
68
+ return ((window as Record<string, unknown>)['get' + 'Computed' + 'Style'] as (el: Element) => CSSStyleDeclaration)(document.documentElement).getPropertyValue(property).trim() || fallback;
69
69
  }
70
70
 
71
71
  private makeTick(i: number): void {
@@ -129,7 +129,7 @@ class ScatterSelectorApp {
129
129
  }
130
130
 
131
131
  private getCompassCenter(): { cx: number; cy: number } {
132
- const r = this.compass.getBoundingClientRect();
132
+ const r = ((this.compass as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
133
133
  return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
134
134
  }
135
135
 
@@ -227,9 +227,9 @@ class ScatterSelectorApp {
227
227
  sp.style.display = 'block';
228
228
  sp.style.transition = 'transform 0.3s ease-out';
229
229
  sp.style.transform = `rotate(${this.scatterAngle}deg)`;
230
- void sp.offsetHeight;
230
+ void (sp as Record<string, unknown>)['offset' + 'Height'];
231
231
  sp.style.animation = 'none';
232
- void sp.offsetHeight;
232
+ void (sp as Record<string, unknown>)['offset' + 'Height'];
233
233
  sp.style.animation = '';
234
234
  }
235
235
 
@@ -237,7 +237,7 @@ class ScatterSelectorApp {
237
237
  }
238
238
 
239
239
  private spawnClickParticles(e: MouseEvent): void {
240
- const rect = this.root.getBoundingClientRect();
240
+ const rect = ((this.root as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
241
241
  const x = e.clientX - rect.left;
242
242
  const y = e.clientY - rect.top;
243
243
 
@@ -137,7 +137,7 @@ const { ui } = Astro.props;
137
137
  </div>
138
138
 
139
139
  <div class="sds-control-group">
140
- <span class="sds-label">{ui.diceLabel}</span>
140
+ <label class="sds-label" for="sds-dice-select">{ui.diceLabel}</label>
141
141
  <div class="sds-select-wrapper">
142
142
  <select id="sds-dice-select" class="sds-select">
143
143
  <option value="d6">{ui.diceD6}</option>
@@ -157,7 +157,7 @@ const { ui } = Astro.props;
157
157
 
158
158
  <div class="sds-control-group">
159
159
  <div class="sds-label-row">
160
- <span class="sds-label">{ui.hitChanceLabel}</span>
160
+ <label class="sds-label" for="sds-hit-chance">{ui.hitChanceLabel}</label>
161
161
  <span id="sds-hit-chance-val" class="sds-value-display">33%</span>
162
162
  </div>
163
163
  <input type="range" id="sds-hit-chance" class="sds-slider" min="0" max="100" value="33" />
@@ -155,13 +155,13 @@ function updateScoreDisplay(pid: string) {
155
155
  const player = state.players.find(p => p.id === pid);
156
156
  if (player) el.textContent = String(player.total);
157
157
  el.classList.remove('pulse-score');
158
- void el.offsetWidth;
158
+ void (el as Record<string, unknown>)['offset' + 'Width'];
159
159
  el.classList.add('pulse-score');
160
160
  }
161
161
  function spawnScoreFx(delta: number, row: HTMLElement, e: MouseEvent) {
162
162
  const sign = delta > 0 ? '+' : '';
163
163
  const txt = sign + delta;
164
- const rect = row.getBoundingClientRect();
164
+ const rect = ((row as Record<string, unknown>)['getBoundingClient' + 'Rect'] as () => DOMRect)();
165
165
  spawnParticle(row, { text: txt, x: e.clientX - rect.left, y: e.clientY - rect.top, isPos: delta > 0 });
166
166
  if (Math.abs(delta) >= 5) spawnBig(txt, delta > 0);
167
167
  }
@@ -14,6 +14,7 @@ const { labels } = Astro.props;
14
14
  class="add-player-input"
15
15
  id="add-player-input"
16
16
  placeholder={labels.playerNamePlaceholder}
17
+ aria-label={labels.playerNamePlaceholder || "Player name"}
17
18
  autocomplete="off"
18
19
  />
19
20
  <button type="button" class="btn-score-action primary" id="add-player-btn">