@jjlmoya/utils-games-development 1.61.0 → 1.62.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 +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/gameUIAccessibilityTester/bibliography.astro +6 -0
- package/src/tool/gameUIAccessibilityTester/bibliography.ts +10 -0
- package/src/tool/gameUIAccessibilityTester/component.astro +210 -0
- package/src/tool/gameUIAccessibilityTester/controller-actions.ts +92 -0
- package/src/tool/gameUIAccessibilityTester/controller.ts +237 -0
- package/src/tool/gameUIAccessibilityTester/dom-views.ts +193 -0
- package/src/tool/gameUIAccessibilityTester/entry.ts +28 -0
- package/src/tool/gameUIAccessibilityTester/evaluator.ts +43 -0
- package/src/tool/gameUIAccessibilityTester/game-ui-accessibility-stress-tester.css +982 -0
- package/src/tool/gameUIAccessibilityTester/i18n/de.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/en.ts +199 -0
- package/src/tool/gameUIAccessibilityTester/i18n/es.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/fr.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/id.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/it.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ja.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ko.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/nl.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pl.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pt.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ru.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/sv.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/tr.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/zh.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/index.ts +11 -0
- package/src/tool/gameUIAccessibilityTester/logic.test.ts +57 -0
- package/src/tool/gameUIAccessibilityTester/logic.ts +157 -0
- package/src/tool/gameUIAccessibilityTester/report.ts +59 -0
- package/src/tool/gameUIAccessibilityTester/seo.astro +15 -0
- package/src/tool/gameUIAccessibilityTester/storage.ts +34 -0
- package/src/tool/gameUIAccessibilityTester/ui.ts +80 -0
- package/src/tools.ts +2 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { spriteSheetPacker } from '../tool/spriteSheetPacker/entry';
|
|
|
4
4
|
import { audioLoopPointFinder } from '../tool/audioLoopPointFinder/entry';
|
|
5
5
|
import { saveFileEditor } from '../tool/saveFileEditor/entry';
|
|
6
6
|
import { steamBbcodeTranslator } from '../tool/steamBbcodeTranslator/entry';
|
|
7
|
+
import { gameUiAccessibilityTester } from '../tool/gameUIAccessibilityTester/entry';
|
|
7
8
|
|
|
8
9
|
export const gamesCategory: GamesCategoryEntry = {
|
|
9
10
|
icon: 'mdi:gamepad-variant',
|
|
10
|
-
tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator],
|
|
11
|
+
tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester],
|
|
11
12
|
i18n: {
|
|
12
13
|
de: () => import('./i18n/de').then((m) => m.content),
|
|
13
14
|
en: () => import('./i18n/en').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -26,5 +26,6 @@ import { localizationSanitizer } from './tool/localizationSanitizer/entry';
|
|
|
26
26
|
import { steamBbcodeTranslator } from './tool/steamBbcodeTranslator/entry';
|
|
27
27
|
import { retroSfxGenerator } from './tool/retroSfxGenerator/entry';
|
|
28
28
|
import { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
|
|
29
|
+
import { gameUiAccessibilityTester } from './tool/gameUIAccessibilityTester/entry';
|
|
29
30
|
|
|
30
|
-
export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper];
|
|
31
|
+
export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester];
|
package/src/index.ts
CHANGED
|
@@ -20,5 +20,6 @@ export { STEAM_CAPSULE_GENERATOR_TOOL, steamCapsuleGenerator } from './tool/stea
|
|
|
20
20
|
export { STEAM_BBCODE_TRANSLATOR_TOOL, steamBbcodeTranslator } from './tool/steamBbcodeTranslator';
|
|
21
21
|
export { RETRO_SFX_GENERATOR_TOOL, retroSfxGenerator } from './tool/retroSfxGenerator';
|
|
22
22
|
export { PIXEL_ART_PALETTE_SWAPPER_TOOL, pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper';
|
|
23
|
+
export { GAME_UI_ACCESSIBILITY_TESTER_TOOL, gameUiAccessibilityTester } from './tool/gameUIAccessibilityTester';
|
|
23
24
|
|
|
24
25
|
export type { ToolLocaleContent as GamesToolLocaleContent } from './types';
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { ALL_TOOLS } from '../tools';
|
|
3
3
|
|
|
4
4
|
describe('Locale Completeness Validation', () => {
|
|
5
|
-
it('
|
|
6
|
-
expect(ALL_TOOLS.length).toBe(
|
|
5
|
+
it('ten tools are registered', () => {
|
|
6
|
+
expect(ALL_TOOLS.length).toBe(10);
|
|
7
7
|
});
|
|
8
8
|
});
|
|
@@ -4,8 +4,8 @@ import { gamesCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 10 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(10);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('gamesCategory should be defined', () => {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const bibliographyEntries = [
|
|
2
|
+
{
|
|
3
|
+
name: 'Machado, Oliveira and Fernandes: A Physiologically Based Model for Simulation of Color Vision Deficiency',
|
|
4
|
+
url: 'https://profs.ic.uff.br/~laffernandes/content/publications/journal/2009_tvcg_15%286%29/machado_oliveira_fernandes-tvcg-15%286%29-2009-corrected.pdf',
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
name: 'W3C Web Accessibility Initiative: Understanding Non Text Contrast',
|
|
8
|
+
url: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast',
|
|
9
|
+
},
|
|
10
|
+
];
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { GameUiAccessibilityTesterUI } from './ui';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
ui: GameUiAccessibilityTesterUI;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
const serializedUi = JSON.stringify(ui).replace(/</g, '\\u003c');
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<article class="gax" data-game-ui-accessibility-tester data-ready="false" data-compare="side" data-zoom="1">
|
|
13
|
+
<script is:inline type="application/json" data-ui set:html={serializedUi}></script>
|
|
14
|
+
|
|
15
|
+
<div class="gax-intro">
|
|
16
|
+
<div class="gax-signal" aria-hidden="true"><span></span><span></span><span></span></div>
|
|
17
|
+
<p>{ui.onboarding}</p>
|
|
18
|
+
<p class="gax-privacy">{ui.privacyNote}</p>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<section class="gax-upload" data-dropzone aria-labelledby="game-ui-drop-title">
|
|
22
|
+
<svg viewBox="0 0 64 64" aria-hidden="true">
|
|
23
|
+
<path d="M12 17h40v30H12z M20 37l8-9 7 7 5-5 8 9 M32 10v13 M26 16l6 7 6-7" />
|
|
24
|
+
</svg>
|
|
25
|
+
<div>
|
|
26
|
+
<h2 id="game-ui-drop-title">{ui.dropTitle}</h2>
|
|
27
|
+
<p>{ui.dropHint}</p>
|
|
28
|
+
<small>{ui.supportedFiles}</small>
|
|
29
|
+
</div>
|
|
30
|
+
<input
|
|
31
|
+
class="gax-file-input"
|
|
32
|
+
id="game-ui-image-input"
|
|
33
|
+
data-file-input
|
|
34
|
+
type="file"
|
|
35
|
+
accept="image/png,image/jpeg,image/webp"
|
|
36
|
+
/>
|
|
37
|
+
<label class="gax-upload-action" for="game-ui-image-input">
|
|
38
|
+
<span class="gax-upload-new">{ui.chooseImage}</span>
|
|
39
|
+
<span class="gax-upload-replace">{ui.replaceImage}</span>
|
|
40
|
+
</label>
|
|
41
|
+
</section>
|
|
42
|
+
|
|
43
|
+
<section class="gax-optics" aria-label={ui.lensLabel}>
|
|
44
|
+
<div class="gax-control-head">
|
|
45
|
+
<span>{ui.lensLabel}</span>
|
|
46
|
+
<span class="gax-control-rule" aria-hidden="true"></span>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="gax-lens-rack">
|
|
49
|
+
<button type="button" data-mode value="original" aria-pressed="false">{ui.lensOriginal}</button>
|
|
50
|
+
<button type="button" data-mode value="protanopia" aria-pressed="false">{ui.lensProtanopia}</button>
|
|
51
|
+
<button type="button" data-mode value="deuteranopia" aria-pressed="true">{ui.lensDeuteranopia}</button>
|
|
52
|
+
<button type="button" data-mode value="tritanopia" aria-pressed="false">{ui.lensTritanopia}</button>
|
|
53
|
+
<button type="button" data-mode value="achromatopsia" aria-pressed="false">{ui.lensAchromatopsia}</button>
|
|
54
|
+
<button type="button" data-mode value="reduced-contrast" aria-pressed="false">{ui.lensReducedContrast}</button>
|
|
55
|
+
<button type="button" data-mode value="desaturation" aria-pressed="false">{ui.lensDesaturation}</button>
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
<section class="gax-stage" aria-label={ui.simulatedView}>
|
|
60
|
+
<div class="gax-stage-toolbar">
|
|
61
|
+
<div class="gax-segment" role="group" aria-label={ui.compareLabel}>
|
|
62
|
+
<button type="button" data-compare value="side" class="is-active" aria-pressed="true">{ui.compareSideBySide}</button>
|
|
63
|
+
<button type="button" data-compare value="split" aria-pressed="false">{ui.compareSplit}</button>
|
|
64
|
+
<button type="button" data-compare value="press" aria-pressed="false">{ui.comparePress}</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="gax-segment" role="group" aria-label={ui.zoomLabel}>
|
|
67
|
+
<button type="button" data-zoom value="1" class="is-active" aria-pressed="true">1x</button>
|
|
68
|
+
<button type="button" data-zoom value="2" aria-pressed="false">2x</button>
|
|
69
|
+
<button type="button" data-zoom value="4" aria-pressed="false">4x</button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="gax-viewports">
|
|
74
|
+
<figure class="gax-viewport gax-original">
|
|
75
|
+
<figcaption><span></span>{ui.originalView}</figcaption>
|
|
76
|
+
<div class="gax-canvas-wrap">
|
|
77
|
+
<canvas data-original-canvas tabindex="0" aria-label={ui.originalView}></canvas>
|
|
78
|
+
<span class="gax-marker gax-marker-a" data-marker="0" hidden>A</span>
|
|
79
|
+
<span class="gax-marker gax-marker-b" data-marker="1" hidden>B</span>
|
|
80
|
+
</div>
|
|
81
|
+
</figure>
|
|
82
|
+
<figure class="gax-viewport gax-simulated">
|
|
83
|
+
<figcaption><span></span>{ui.simulatedView}</figcaption>
|
|
84
|
+
<div class="gax-canvas-wrap">
|
|
85
|
+
<canvas data-simulated-canvas aria-label={ui.simulatedView}></canvas>
|
|
86
|
+
</div>
|
|
87
|
+
</figure>
|
|
88
|
+
<p class="gax-empty">{ui.emptyCanvas}</p>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div class="gax-compare-actions">
|
|
92
|
+
<label class="gax-split-control" for="game-ui-split">
|
|
93
|
+
{ui.splitPosition}
|
|
94
|
+
<input id="game-ui-split" data-split type="range" min="10" max="90" value="50" />
|
|
95
|
+
</label>
|
|
96
|
+
<button type="button" class="gax-hold" data-hold-original>{ui.holdOriginal}</button>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
|
|
100
|
+
<section class="gax-stress" aria-labelledby="game-ui-stress-title">
|
|
101
|
+
<div class="gax-control-head">
|
|
102
|
+
<h2 id="game-ui-stress-title">{ui.stressLabel}</h2>
|
|
103
|
+
<span class="gax-control-rule" aria-hidden="true"></span>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="gax-stress-line">
|
|
106
|
+
<label for="game-ui-blur">{ui.blurLabel}</label>
|
|
107
|
+
<input id="game-ui-blur" data-blur type="range" min="0" max="8" step="0.5" value="0" />
|
|
108
|
+
<div class="gax-segment" role="group" aria-label={ui.downscaleLabel}>
|
|
109
|
+
<button type="button" data-downscale value="1" class="is-active" aria-pressed="true">{ui.downscaleFull}</button>
|
|
110
|
+
<button type="button" data-downscale value="0.5" aria-pressed="false">{ui.downscaleHalf}</button>
|
|
111
|
+
<button type="button" data-downscale value="0.25" aria-pressed="false">{ui.downscaleQuarter}</button>
|
|
112
|
+
<button type="button" data-downscale value="0.125" aria-pressed="false">{ui.downscaleEighth}</button>
|
|
113
|
+
</div>
|
|
114
|
+
<label class="gax-toggle">
|
|
115
|
+
<input data-heatmap type="checkbox" />
|
|
116
|
+
<span aria-hidden="true"></span>
|
|
117
|
+
<span><strong>{ui.heatmapLabel}</strong><small>{ui.heatmapHint}</small></span>
|
|
118
|
+
</label>
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
121
|
+
|
|
122
|
+
<section class="gax-sampler" aria-labelledby="game-ui-sample-title">
|
|
123
|
+
<header>
|
|
124
|
+
<div>
|
|
125
|
+
<h2 id="game-ui-sample-title">{ui.sampleTitle}</h2>
|
|
126
|
+
<p>{ui.sampleInstructions}</p>
|
|
127
|
+
</div>
|
|
128
|
+
<span class="gax-classification">{ui.heuristicLabel}</span>
|
|
129
|
+
</header>
|
|
130
|
+
<div class="gax-sample-strip">
|
|
131
|
+
<div class="gax-sample-unit">
|
|
132
|
+
<button type="button" data-sample-button value="0" class="is-active" aria-pressed="true">
|
|
133
|
+
<span data-swatch="0" class="gax-swatch"></span>{ui.sampleA}
|
|
134
|
+
</button>
|
|
135
|
+
<div class="gax-sample-copy">
|
|
136
|
+
<label for="game-ui-sample-a">{ui.sampleAName}</label>
|
|
137
|
+
<input id="game-ui-sample-a" data-name="0" type="text" value={ui.sampleAInitial} maxlength="40" />
|
|
138
|
+
<code data-hex="0">{ui.noSample}</code>
|
|
139
|
+
</div>
|
|
140
|
+
<label class="gax-color-input" for="game-ui-color-a">{ui.manualColor}</label>
|
|
141
|
+
<input id="game-ui-color-a" data-color-input="0" type="color" value="#000000" />
|
|
142
|
+
</div>
|
|
143
|
+
<div class="gax-link-beam" aria-hidden="true"><span></span></div>
|
|
144
|
+
<div class="gax-sample-unit">
|
|
145
|
+
<button type="button" data-sample-button value="1" aria-pressed="false">
|
|
146
|
+
<span data-swatch="1" class="gax-swatch"></span>{ui.sampleB}
|
|
147
|
+
</button>
|
|
148
|
+
<div class="gax-sample-copy">
|
|
149
|
+
<label for="game-ui-sample-b">{ui.sampleBName}</label>
|
|
150
|
+
<input id="game-ui-sample-b" data-name="1" type="text" value={ui.sampleBInitial} maxlength="40" />
|
|
151
|
+
<code data-hex="1">{ui.noSample}</code>
|
|
152
|
+
</div>
|
|
153
|
+
<label class="gax-color-input" for="game-ui-color-b">{ui.manualColor}</label>
|
|
154
|
+
<input id="game-ui-color-b" data-color-input="1" type="color" value="#000000" />
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="gax-instruments" aria-live="polite">
|
|
158
|
+
<div><span>{ui.originalContrast}</span><strong data-original-contrast>--</strong><small>{ui.measurementLabel}</small></div>
|
|
159
|
+
<div><span>{ui.simulatedContrast}</span><strong data-simulated-contrast>--</strong><small>{ui.measurementLabel}</small></div>
|
|
160
|
+
<div><span>{ui.separationRetained}</span><strong data-retained>--</strong><small>{ui.heuristicLabel}</small></div>
|
|
161
|
+
<div class="gax-diagnostic"><span>{ui.manualReviewLabel}</span><strong data-diagnostic data-level="pending">{ui.statusPending}</strong></div>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="gax-method-notes">
|
|
164
|
+
<p><strong>{ui.measurementLabel}</strong>{ui.measurementHint}</p>
|
|
165
|
+
<p><strong>{ui.heuristicLabel}</strong>{ui.heuristicHint}</p>
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
|
|
169
|
+
<section class="gax-review" aria-labelledby="game-ui-review-title">
|
|
170
|
+
<div class="gax-review-rail">
|
|
171
|
+
<h2 id="game-ui-review-title">{ui.promptTitle}</h2>
|
|
172
|
+
<ol>
|
|
173
|
+
<li>{ui.promptColorOnly}</li>
|
|
174
|
+
<li>{ui.promptChangingBackground}</li>
|
|
175
|
+
<li>{ui.promptMinimap}</li>
|
|
176
|
+
<li>{ui.promptStates}</li>
|
|
177
|
+
<li>{ui.promptShape}</li>
|
|
178
|
+
</ol>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="gax-findings-panel">
|
|
181
|
+
<label for="game-ui-finding">{ui.findingLabel}</label>
|
|
182
|
+
<div class="gax-finding-entry">
|
|
183
|
+
<input id="game-ui-finding" data-finding-input type="text" placeholder={ui.findingPlaceholder} maxlength="180" />
|
|
184
|
+
<button type="button" data-add-finding>{ui.addFinding}</button>
|
|
185
|
+
</div>
|
|
186
|
+
<p data-findings-empty>{ui.findingsEmpty}</p>
|
|
187
|
+
<ul data-findings></ul>
|
|
188
|
+
</div>
|
|
189
|
+
</section>
|
|
190
|
+
|
|
191
|
+
<footer class="gax-actions">
|
|
192
|
+
<div>
|
|
193
|
+
<p>{ui.localOnlyDisclosure}</p>
|
|
194
|
+
<p>{ui.limitationDisclosure}</p>
|
|
195
|
+
<p data-status role="status">{ui.noSample}</p>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="gax-action-buttons">
|
|
198
|
+
<button type="button" data-export-sheet disabled>{ui.exportSheet}</button>
|
|
199
|
+
<button type="button" data-export-report disabled>{ui.exportReport}</button>
|
|
200
|
+
<button type="button" data-reset>{ui.resetTool}</button>
|
|
201
|
+
</div>
|
|
202
|
+
</footer>
|
|
203
|
+
</article>
|
|
204
|
+
|
|
205
|
+
<script>
|
|
206
|
+
import { mountGameUiAccessibilityTester } from './controller';
|
|
207
|
+
|
|
208
|
+
document.querySelectorAll<HTMLElement>('[data-game-ui-accessibility-tester]')
|
|
209
|
+
.forEach((root) => mountGameUiAccessibilityTester(root));
|
|
210
|
+
</script>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { evaluatePair } from './evaluator';
|
|
2
|
+
import { createComparisonSheet, downloadCanvas } from './dom-views';
|
|
3
|
+
import { downloadStructuredReport } from './report';
|
|
4
|
+
import { clearSettings } from './storage';
|
|
5
|
+
import type { TesterState } from './controller';
|
|
6
|
+
|
|
7
|
+
function required<T extends Element>(root: ParentNode, selector: string): T {
|
|
8
|
+
const element = root.querySelector<T>(selector);
|
|
9
|
+
if (!element) throw new Error(`Missing tester element ${selector}`);
|
|
10
|
+
return element;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setStatus(state: TesterState, message: string): void {
|
|
14
|
+
required<HTMLElement>(state.root, '[data-status]').textContent = message;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function addFinding(state: TesterState): void {
|
|
18
|
+
const input = required<HTMLInputElement>(state.root, '[data-finding-input]');
|
|
19
|
+
const finding = input.value.trim();
|
|
20
|
+
if (!finding) return;
|
|
21
|
+
state.findings.push(finding);
|
|
22
|
+
const item = document.createElement('li');
|
|
23
|
+
item.textContent = finding;
|
|
24
|
+
required(state.root, '[data-findings]').append(item);
|
|
25
|
+
required<HTMLElement>(state.root, '[data-findings-empty]').hidden = true;
|
|
26
|
+
input.value = '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function exportReport(state: TesterState): void {
|
|
30
|
+
const [first, second] = state.samples;
|
|
31
|
+
if (!first || !second) return;
|
|
32
|
+
downloadStructuredReport({
|
|
33
|
+
mode: state.settings.mode,
|
|
34
|
+
blur: state.settings.blur,
|
|
35
|
+
downscale: state.settings.downscale,
|
|
36
|
+
firstName: required<HTMLInputElement>(state.root, '[data-name="0"]').value,
|
|
37
|
+
secondName: required<HTMLInputElement>(state.root, '[data-name="1"]').value,
|
|
38
|
+
first,
|
|
39
|
+
second,
|
|
40
|
+
diagnostic: evaluatePair(first, second, state.settings.mode),
|
|
41
|
+
findings: state.findings,
|
|
42
|
+
}, state.ui);
|
|
43
|
+
setStatus(state, state.ui.reportDownloaded);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function exportSheet(state: TesterState): void {
|
|
47
|
+
if (!state.source) return;
|
|
48
|
+
const simulated = required<HTMLCanvasElement>(state.root, '[data-simulated-canvas]');
|
|
49
|
+
const settings = `${state.ui.lensLabel}: ${state.settings.mode}, ${state.ui.blurLabel}: ${state.settings.blur}px`;
|
|
50
|
+
const sheet = createComparisonSheet(state.source, simulated, {
|
|
51
|
+
title: state.ui.reportTitle,
|
|
52
|
+
original: state.ui.originalView,
|
|
53
|
+
simulated: state.ui.simulatedView,
|
|
54
|
+
settings,
|
|
55
|
+
localOnly: state.ui.localOnlyDisclosure,
|
|
56
|
+
});
|
|
57
|
+
downloadCanvas(sheet, 'game-ui-accessibility-comparison.png');
|
|
58
|
+
setStatus(state, state.ui.sheetDownloaded);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function reset(state: TesterState): void {
|
|
62
|
+
clearSettings();
|
|
63
|
+
state.source = null;
|
|
64
|
+
state.samples = [null, null];
|
|
65
|
+
state.findings = [];
|
|
66
|
+
state.root.dataset.ready = 'false';
|
|
67
|
+
state.root.querySelectorAll<HTMLButtonElement>('[data-export-sheet], [data-export-report]')
|
|
68
|
+
.forEach((button) => { button.disabled = true; });
|
|
69
|
+
required(state.root, '[data-findings]').replaceChildren();
|
|
70
|
+
required<HTMLElement>(state.root, '[data-findings-empty]').hidden = false;
|
|
71
|
+
required<HTMLInputElement>(state.root, '[data-file-input]').value = '';
|
|
72
|
+
setStatus(state, state.ui.noSample);
|
|
73
|
+
const diagnostic = required<HTMLElement>(state.root, '[data-diagnostic]');
|
|
74
|
+
diagnostic.textContent = state.ui.statusPending;
|
|
75
|
+
diagnostic.dataset.level = 'pending';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function bindHold(state: TesterState): void {
|
|
79
|
+
const hold = required(state.root, '[data-hold-original]');
|
|
80
|
+
hold.addEventListener('pointerdown', () => { state.root.dataset.hold = 'true'; });
|
|
81
|
+
['pointerup', 'pointerleave', 'blur'].forEach((event) => {
|
|
82
|
+
hold.addEventListener(event, () => { delete state.root.dataset.hold; });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function bindActions(state: TesterState): void {
|
|
87
|
+
required(state.root, '[data-add-finding]').addEventListener('click', () => addFinding(state));
|
|
88
|
+
required(state.root, '[data-export-report]').addEventListener('click', () => exportReport(state));
|
|
89
|
+
required(state.root, '[data-export-sheet]').addEventListener('click', () => exportSheet(state));
|
|
90
|
+
required(state.root, '[data-reset]').addEventListener('click', () => reset(state));
|
|
91
|
+
bindHold(state);
|
|
92
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { evaluatePair, type PairDiagnostic } from './evaluator';
|
|
2
|
+
import {
|
|
3
|
+
copyOriginal,
|
|
4
|
+
loadImageFile,
|
|
5
|
+
renderSimulation,
|
|
6
|
+
sampleAtFraction,
|
|
7
|
+
sampleCanvas,
|
|
8
|
+
} from './dom-views';
|
|
9
|
+
import { fromHex, toHex, type RGBColor } from './logic';
|
|
10
|
+
import { loadSettings, saveSettings, type StoredSettings } from './storage';
|
|
11
|
+
import type { GameUiAccessibilityTesterUI } from './ui';
|
|
12
|
+
import { bindActions } from './controller-actions';
|
|
13
|
+
|
|
14
|
+
export interface TesterState {
|
|
15
|
+
root: HTMLElement;
|
|
16
|
+
ui: GameUiAccessibilityTesterUI;
|
|
17
|
+
settings: StoredSettings;
|
|
18
|
+
source: HTMLCanvasElement | null;
|
|
19
|
+
samples: [RGBColor | null, RGBColor | null];
|
|
20
|
+
positions: [{ left: number; top: number }, { left: number; top: number }];
|
|
21
|
+
activeSample: 0 | 1;
|
|
22
|
+
findings: string[];
|
|
23
|
+
frame: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const FALLBACK_SETTINGS: StoredSettings = {
|
|
27
|
+
mode: 'deuteranopia', compare: 'side', blur: 0, downscale: 1, zoom: 1, heatmap: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function required<T extends Element>(root: ParentNode, selector: string): T {
|
|
31
|
+
const element = root.querySelector<T>(selector);
|
|
32
|
+
if (!element) throw new Error(`Missing tester element ${selector}`);
|
|
33
|
+
return element;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readUi(root: HTMLElement): GameUiAccessibilityTesterUI {
|
|
37
|
+
const script = required<HTMLScriptElement>(root, '[data-ui]');
|
|
38
|
+
return JSON.parse(script.textContent ?? '{}') as GameUiAccessibilityTesterUI;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setStatus(state: TesterState, message: string): void {
|
|
42
|
+
required<HTMLElement>(state.root, '[data-status]').textContent = message;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setExportsEnabled(state: TesterState, enabled: boolean): void {
|
|
46
|
+
state.root.querySelectorAll<HTMLButtonElement>('[data-export-sheet], [data-export-report]')
|
|
47
|
+
.forEach((button) => { button.disabled = !enabled; });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setActive(root: HTMLElement, selector: string, value: string): void {
|
|
51
|
+
root.querySelectorAll<HTMLButtonElement>(selector).forEach((button) => {
|
|
52
|
+
const active = button.value === value;
|
|
53
|
+
button.classList.toggle('is-active', active);
|
|
54
|
+
button.setAttribute('aria-pressed', String(active));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function diagnosticLabel(state: TesterState, diagnostic: PairDiagnostic): string {
|
|
59
|
+
if (diagnostic.status === 'review') return state.ui.statusReview;
|
|
60
|
+
if (diagnostic.status === 'watch') return state.ui.statusWatch;
|
|
61
|
+
return state.ui.statusStrong;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderMetrics(state: TesterState): void {
|
|
65
|
+
const [first, second] = state.samples;
|
|
66
|
+
if (!first || !second) return;
|
|
67
|
+
const diagnostic = evaluatePair(first, second, state.settings.mode);
|
|
68
|
+
required(state.root, '[data-original-contrast]').textContent = `${diagnostic.originalContrast.toFixed(2)}:1`;
|
|
69
|
+
required(state.root, '[data-simulated-contrast]').textContent = `${diagnostic.simulatedContrast.toFixed(2)}:1`;
|
|
70
|
+
required(state.root, '[data-retained]').textContent = `${diagnostic.retained.toFixed(0)}%`;
|
|
71
|
+
const badge = required(state.root, '[data-diagnostic]');
|
|
72
|
+
badge.textContent = diagnosticLabel(state, diagnostic);
|
|
73
|
+
badge.setAttribute('data-level', diagnostic.status);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderNow(state: TesterState): void {
|
|
77
|
+
state.frame = 0;
|
|
78
|
+
if (!state.source) return;
|
|
79
|
+
const original = required<HTMLCanvasElement>(state.root, '[data-original-canvas]');
|
|
80
|
+
const simulated = required<HTMLCanvasElement>(state.root, '[data-simulated-canvas]');
|
|
81
|
+
copyOriginal(state.source, original);
|
|
82
|
+
renderSimulation({
|
|
83
|
+
source: state.source,
|
|
84
|
+
destination: simulated,
|
|
85
|
+
mode: state.settings.mode,
|
|
86
|
+
blur: state.settings.blur,
|
|
87
|
+
downscale: state.settings.downscale,
|
|
88
|
+
heatmap: state.settings.heatmap,
|
|
89
|
+
});
|
|
90
|
+
state.root.dataset.compare = state.settings.compare;
|
|
91
|
+
state.root.dataset.zoom = String(state.settings.zoom);
|
|
92
|
+
renderMetrics(state);
|
|
93
|
+
saveSettings(state.settings);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scheduleRender(state: TesterState): void {
|
|
97
|
+
if (state.frame) cancelAnimationFrame(state.frame);
|
|
98
|
+
state.frame = requestAnimationFrame(() => renderNow(state));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function placeMarker(state: TesterState, index: 0 | 1): void {
|
|
102
|
+
const marker = required<HTMLElement>(state.root, `[data-marker="${index}"]`);
|
|
103
|
+
const position = state.positions[index];
|
|
104
|
+
marker.style.setProperty('--marker-x', `${position.left * 100}%`);
|
|
105
|
+
marker.style.setProperty('--marker-y', `${position.top * 100}%`);
|
|
106
|
+
marker.hidden = false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderSample(state: TesterState, index: 0 | 1): void {
|
|
110
|
+
const color = state.samples[index];
|
|
111
|
+
if (!color) return;
|
|
112
|
+
const chip = required<HTMLElement>(state.root, `[data-swatch="${index}"]`);
|
|
113
|
+
chip.style.backgroundColor = toHex(color);
|
|
114
|
+
required(state.root, `[data-hex="${index}"]`).textContent = toHex(color);
|
|
115
|
+
required<HTMLInputElement>(state.root, `[data-color-input="${index}"]`).value = toHex(color);
|
|
116
|
+
placeMarker(state, index);
|
|
117
|
+
renderMetrics(state);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function selectSample(state: TesterState, index: 0 | 1): void {
|
|
121
|
+
state.activeSample = index;
|
|
122
|
+
setActive(state.root, '[data-sample-button]', String(index));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sampleFromEvent(state: TesterState, event: MouseEvent): void {
|
|
126
|
+
if (!state.source) return;
|
|
127
|
+
const canvas = required<HTMLCanvasElement>(state.root, '[data-original-canvas]');
|
|
128
|
+
const sample = sampleCanvas(canvas, event.clientX, event.clientY);
|
|
129
|
+
const index = state.activeSample;
|
|
130
|
+
state.samples[index] = sample.color;
|
|
131
|
+
state.positions[index] = { left: sample.left, top: sample.top };
|
|
132
|
+
renderSample(state, index);
|
|
133
|
+
selectSample(state, index === 0 ? 1 : 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function seedSamples(state: TesterState): void {
|
|
137
|
+
if (!state.source) return;
|
|
138
|
+
state.positions = [{ left: 0.35, top: 0.5 }, { left: 0.65, top: 0.5 }];
|
|
139
|
+
state.samples = [
|
|
140
|
+
sampleAtFraction(state.source, 0.35, 0.5),
|
|
141
|
+
sampleAtFraction(state.source, 0.65, 0.5),
|
|
142
|
+
];
|
|
143
|
+
renderSample(state, 0);
|
|
144
|
+
renderSample(state, 1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function acceptFile(state: TesterState, file: File): Promise<void> {
|
|
148
|
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) return setStatus(state, state.ui.uploadError);
|
|
149
|
+
if (file.size > 16 * 1024 * 1024) return setStatus(state, state.ui.fileTooLarge);
|
|
150
|
+
try {
|
|
151
|
+
state.source = await loadImageFile(file);
|
|
152
|
+
state.root.dataset.ready = 'true';
|
|
153
|
+
setExportsEnabled(state, true);
|
|
154
|
+
seedSamples(state);
|
|
155
|
+
renderNow(state);
|
|
156
|
+
setStatus(state, state.ui.imageReady);
|
|
157
|
+
} catch {
|
|
158
|
+
setStatus(state, state.ui.uploadError);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function bindFileControls(state: TesterState): void {
|
|
163
|
+
const input = required<HTMLInputElement>(state.root, '[data-file-input]');
|
|
164
|
+
const dropzone = required<HTMLElement>(state.root, '[data-dropzone]');
|
|
165
|
+
input.addEventListener('change', () => { if (input.files?.[0]) void acceptFile(state, input.files[0]); });
|
|
166
|
+
dropzone.addEventListener('dragover', (event) => { event.preventDefault(); dropzone.dataset.drag = 'true'; });
|
|
167
|
+
dropzone.addEventListener('dragleave', () => { delete dropzone.dataset.drag; });
|
|
168
|
+
dropzone.addEventListener('drop', (event) => {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
delete dropzone.dataset.drag;
|
|
171
|
+
if (event.dataTransfer?.files[0]) void acceptFile(state, event.dataTransfer.files[0]);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function bindChoiceButtons(state: TesterState, selector: string, key: keyof StoredSettings): void {
|
|
176
|
+
state.root.querySelectorAll<HTMLButtonElement>(selector).forEach((button) => {
|
|
177
|
+
button.addEventListener('click', () => {
|
|
178
|
+
const value = key === 'downscale' || key === 'zoom' ? Number(button.value) : button.value;
|
|
179
|
+
Object.assign(state.settings, { [key]: value });
|
|
180
|
+
setActive(state.root, selector, button.value);
|
|
181
|
+
scheduleRender(state);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function bindStressControls(state: TesterState): void {
|
|
187
|
+
const blur = required<HTMLInputElement>(state.root, '[data-blur]');
|
|
188
|
+
const split = required<HTMLInputElement>(state.root, '[data-split]');
|
|
189
|
+
const heatmap = required<HTMLInputElement>(state.root, '[data-heatmap]');
|
|
190
|
+
blur.addEventListener('input', () => { state.settings.blur = Number(blur.value); scheduleRender(state); });
|
|
191
|
+
split.addEventListener('input', () => state.root.style.setProperty('--split', `${split.value}%`));
|
|
192
|
+
heatmap.addEventListener('change', () => { state.settings.heatmap = heatmap.checked; scheduleRender(state); });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function syncStoredControls(state: TesterState): void {
|
|
196
|
+
required<HTMLInputElement>(state.root, '[data-blur]').value = String(state.settings.blur);
|
|
197
|
+
required<HTMLInputElement>(state.root, '[data-heatmap]').checked = state.settings.heatmap;
|
|
198
|
+
state.root.dataset.compare = state.settings.compare;
|
|
199
|
+
state.root.dataset.zoom = String(state.settings.zoom);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function bindSampling(state: TesterState): void {
|
|
203
|
+
required(state.root, '[data-original-canvas]').addEventListener('click', (event) => sampleFromEvent(state, event as MouseEvent));
|
|
204
|
+
state.root.querySelectorAll<HTMLButtonElement>('[data-sample-button]').forEach((button) => {
|
|
205
|
+
button.addEventListener('click', () => selectSample(state, Number(button.value) as 0 | 1));
|
|
206
|
+
});
|
|
207
|
+
state.root.querySelectorAll<HTMLInputElement>('[data-color-input]').forEach((input) => {
|
|
208
|
+
input.addEventListener('input', () => {
|
|
209
|
+
const index = Number(input.dataset.colorInput) as 0 | 1;
|
|
210
|
+
state.samples[index] = fromHex(input.value);
|
|
211
|
+
renderSample(state, index);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function mountGameUiAccessibilityTester(root: HTMLElement): void {
|
|
217
|
+
if (root.dataset.mounted === 'true') return;
|
|
218
|
+
const state: TesterState = {
|
|
219
|
+
root, ui: readUi(root), settings: loadSettings(FALLBACK_SETTINGS), source: null,
|
|
220
|
+
samples: [null, null], positions: [{ left: 0.35, top: 0.5 }, { left: 0.65, top: 0.5 }],
|
|
221
|
+
activeSample: 0, findings: [], frame: 0,
|
|
222
|
+
};
|
|
223
|
+
root.dataset.mounted = 'true';
|
|
224
|
+
bindFileControls(state);
|
|
225
|
+
bindChoiceButtons(state, '[data-mode]', 'mode');
|
|
226
|
+
bindChoiceButtons(state, '[data-compare]', 'compare');
|
|
227
|
+
bindChoiceButtons(state, '[data-downscale]', 'downscale');
|
|
228
|
+
bindChoiceButtons(state, '[data-zoom]', 'zoom');
|
|
229
|
+
bindStressControls(state);
|
|
230
|
+
bindSampling(state);
|
|
231
|
+
bindActions(state);
|
|
232
|
+
setActive(root, '[data-mode]', state.settings.mode);
|
|
233
|
+
setActive(root, '[data-compare]', state.settings.compare);
|
|
234
|
+
setActive(root, '[data-downscale]', String(state.settings.downscale));
|
|
235
|
+
setActive(root, '[data-zoom]', String(state.settings.zoom));
|
|
236
|
+
syncStoredControls(state);
|
|
237
|
+
}
|