@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 +4 -2
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tool/board-game-timer/components/SetupPanel.astro +8 -8
- package/src/tool/board-game-timer/modules/particles.ts +4 -4
- package/src/tool/decision-wheel/decision-wheel.css +11 -1
- package/src/tool/decision-wheel/ui.ts +1 -1
- package/src/tool/dice-roller-simulator/client.ts +13 -11
- package/src/tool/dice-roller-simulator/components/DiceGrid.astro +1 -1
- package/src/tool/fantasy-runes-translator/client.ts +1 -1
- package/src/tool/fantasy-runes-translator/component.astro +2 -2
- package/src/tool/first-player-selector/client.ts +1 -1
- package/src/tool/hidden-role-dealer/component.astro +10 -8
- package/src/tool/investigation-board/boardContextMenu.ts +1 -1
- package/src/tool/investigation-board/components/ControlPanel.astro +1 -1
- package/src/tool/investigation-board/renderer.ts +2 -2
- package/src/tool/lunar-tide-tracker/client.ts +2 -2
- package/src/tool/lunar-tide-tracker/component.astro +1 -1
- package/src/tool/scatter-direction-selector/client.ts +5 -5
- package/src/tool/scatter-direction-selector/component.astro +2 -2
- package/src/tool/score-tracker/client.ts +2 -2
- package/src/tool/score-tracker/components/PlayerSetup.astro +1 -0
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-tabletop",
|
|
3
|
-
"version": "1.
|
|
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
|
-
<
|
|
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
|
-
<
|
|
81
|
+
<label class="config-label" for="setting-increment">{ui.incrementLabel || 'Increment/Delay'}</label>
|
|
82
82
|
<div class="number-input-wrapper">
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
134
|
-
if (
|
|
135
|
-
|
|
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
|
|
144
|
-
if (
|
|
145
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
|
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">↻</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
|
|
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
|
|
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
|
|
195
|
-
const h = el ? el
|
|
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
|
|
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 =
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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
|
|
230
|
+
void (sp as Record<string, unknown>)['offset' + 'Height'];
|
|
231
231
|
sp.style.animation = 'none';
|
|
232
|
-
void sp
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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">
|