@jjlmoya/utils-games-development 1.55.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +5 -1
  3. package/src/index.ts +1 -0
  4. package/src/tests/contrast_legibility.test.ts +56 -10
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/runtime_registry.test.ts +27 -0
  7. package/src/tests/tool_validation.test.ts +2 -2
  8. package/src/tool/localizationSanitizer/localization-sanitizer.css +4 -2
  9. package/src/tool/pixelArtPaletteSwapper/app-client.ts +234 -0
  10. package/src/tool/pixelArtPaletteSwapper/bibliography.astro +6 -0
  11. package/src/tool/pixelArtPaletteSwapper/bibliography.ts +16 -0
  12. package/src/tool/pixelArtPaletteSwapper/component.astro +153 -0
  13. package/src/tool/pixelArtPaletteSwapper/entry.ts +28 -0
  14. package/src/tool/pixelArtPaletteSwapper/i18n/de.ts +233 -0
  15. package/src/tool/pixelArtPaletteSwapper/i18n/en.ts +233 -0
  16. package/src/tool/pixelArtPaletteSwapper/i18n/es.ts +233 -0
  17. package/src/tool/pixelArtPaletteSwapper/i18n/fr.ts +233 -0
  18. package/src/tool/pixelArtPaletteSwapper/i18n/id.ts +233 -0
  19. package/src/tool/pixelArtPaletteSwapper/i18n/it.ts +233 -0
  20. package/src/tool/pixelArtPaletteSwapper/i18n/ja.ts +225 -0
  21. package/src/tool/pixelArtPaletteSwapper/i18n/ko.ts +225 -0
  22. package/src/tool/pixelArtPaletteSwapper/i18n/nl.ts +233 -0
  23. package/src/tool/pixelArtPaletteSwapper/i18n/pl.ts +233 -0
  24. package/src/tool/pixelArtPaletteSwapper/i18n/pt.ts +233 -0
  25. package/src/tool/pixelArtPaletteSwapper/i18n/ru.ts +233 -0
  26. package/src/tool/pixelArtPaletteSwapper/i18n/sv.ts +233 -0
  27. package/src/tool/pixelArtPaletteSwapper/i18n/tr.ts +233 -0
  28. package/src/tool/pixelArtPaletteSwapper/i18n/zh.ts +225 -0
  29. package/src/tool/pixelArtPaletteSwapper/index.ts +12 -0
  30. package/src/tool/pixelArtPaletteSwapper/logic.test.ts +43 -0
  31. package/src/tool/pixelArtPaletteSwapper/logic.ts +201 -0
  32. package/src/tool/pixelArtPaletteSwapper/pixel-art-palette-swapper.css +728 -0
  33. package/src/tool/pixelArtPaletteSwapper/seo.astro +15 -0
  34. package/src/tool/pixelArtPaletteSwapper/ui.ts +35 -0
  35. package/src/tool/retroSfxGenerator/client.ts +9 -2
  36. package/src/tool/retroSfxGenerator/logic.test.ts +21 -0
  37. package/src/tool/retroSfxGenerator/logic.ts +1 -1
  38. package/src/tool/retroSfxGenerator/retro-sfx-generator.css +10 -4
  39. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-games-development",
3
- "version": "1.55.0",
3
+ "version": "1.57.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -14,6 +14,8 @@ export { steamBbcodeTranslator } from './tool/steamBbcodeTranslator/entry';
14
14
  export type { SteamBbcodeTranslatorUI, SteamBbcodeTranslatorLocaleContent } from './tool/steamBbcodeTranslator/entry';
15
15
  export { retroSfxGenerator } from './tool/retroSfxGenerator/entry';
16
16
  export type { RetroSfxGeneratorUI, RetroSfxGeneratorLocaleContent } from './tool/retroSfxGenerator/entry';
17
+ export { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
18
+ export type { PixelArtPaletteSwapperUI, PixelArtPaletteSwapperLocaleContent } from './tool/pixelArtPaletteSwapper/entry';
17
19
  export { gamesCategory } from './category';
18
20
  import { steamCapsuleGenerator } from './tool/steamCapsuleGenerator/entry';
19
21
  import { itchioGameTester } from './tool/itchioGameTester/entry';
@@ -21,6 +23,8 @@ import { spriteSheetPacker } from './tool/spriteSheetPacker/entry';
21
23
  import { audioLoopPointFinder } from './tool/audioLoopPointFinder/entry';
22
24
  import { saveFileEditor } from './tool/saveFileEditor/entry';
23
25
  import { localizationSanitizer } from './tool/localizationSanitizer/entry';
26
+ import { steamBbcodeTranslator } from './tool/steamBbcodeTranslator/entry';
24
27
  import { retroSfxGenerator } from './tool/retroSfxGenerator/entry';
28
+ import { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
25
29
 
26
- export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, retroSfxGenerator];
30
+ export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper];
package/src/index.ts CHANGED
@@ -19,5 +19,6 @@ export { ALL_ENTRIES, ALL_TOOLS } from './tools';
19
19
  export { STEAM_CAPSULE_GENERATOR_TOOL, steamCapsuleGenerator } from './tool/steamCapsuleGenerator';
20
20
  export { STEAM_BBCODE_TRANSLATOR_TOOL, steamBbcodeTranslator } from './tool/steamBbcodeTranslator';
21
21
  export { RETRO_SFX_GENERATOR_TOOL, retroSfxGenerator } from './tool/retroSfxGenerator';
22
+ export { PIXEL_ART_PALETTE_SWAPPER_TOOL, pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper';
22
23
 
23
24
  export type { ToolLocaleContent as GamesToolLocaleContent } from './types';
@@ -5,10 +5,13 @@ import { join, relative } from 'node:path';
5
5
  type Color = { r: number; g: number; b: number; a: number };
6
6
  type Declaration = { name: string; value: string };
7
7
  type CssRule = { selector: string; declarations: Declaration[]; line: number };
8
+ type Theme = 'light' | 'dark';
9
+ type Variables = Record<string, string>;
8
10
 
9
11
  const toolRoot = join(process.cwd(), 'src', 'tool');
10
12
  const minimumTextContrast = 4.5;
11
13
  const minimumReviewFontSizeRem = 0.75;
14
+ const themes: Theme[] = ['light', 'dark'];
12
15
 
13
16
  function findFiles(directory: string, extensions: string[]): string[] {
14
17
  const files: string[] = [];
@@ -184,14 +187,48 @@ function simpleBackground(value: string): Color | null {
184
187
  return parseColor(value);
185
188
  }
186
189
 
187
- function contrastFinding(path: string, rule: CssRule): string | null {
190
+ function isThemeSelector(selector: string, theme: Theme): boolean {
191
+ return selector.includes(`.theme-${theme}`);
192
+ }
193
+
194
+ function isActiveRule(selector: string, theme: Theme): boolean {
195
+ const otherTheme: Theme = theme === 'dark' ? 'light' : 'dark';
196
+ return !isThemeSelector(selector, otherTheme);
197
+ }
198
+
199
+ function addVariables(target: Variables, rule: CssRule): void {
200
+ rule.declarations
201
+ .filter((declaration) => declaration.name.startsWith('--'))
202
+ .forEach((declaration) => { target[declaration.name] = declaration.value; });
203
+ }
204
+
205
+ function collectVariables(rules: CssRule[], theme: Theme): Variables {
206
+ const variables: Variables = {};
207
+ rules.filter((rule) => !isThemeSelector(rule.selector, 'dark') && !isThemeSelector(rule.selector, 'light'))
208
+ .forEach((rule) => addVariables(variables, rule));
209
+ rules.filter((rule) => isThemeSelector(rule.selector, theme))
210
+ .forEach((rule) => addVariables(variables, rule));
211
+ return variables;
212
+ }
213
+
214
+ function resolveCssValue(value: string, variables: Variables): string {
215
+ let resolved = value;
216
+ for (let attempt = 0; attempt < 10; attempt += 1) {
217
+ const next = resolved.replace(/var\(\s*(--[\w-]+)(?:\s*,\s*([^()]+))?\s*\)/g, (_, name, fallback) => variables[name] ?? fallback ?? '');
218
+ if (next === resolved) return resolved;
219
+ resolved = next;
220
+ }
221
+ return resolved;
222
+ }
223
+
224
+ function contrastFinding(path: string, rule: CssRule, theme: Theme, variables: Variables): string | null {
188
225
  const foreground = lastDeclaration(rule.declarations, ['color']);
189
226
  const background = lastDeclaration(rule.declarations, ['background-color', 'background']);
190
- const foregroundColor = foreground ? parseColor(foreground.value) : null;
191
- const backgroundColor = background ? simpleBackground(background.value) : null;
227
+ const foregroundColor = foreground ? parseColor(resolveCssValue(foreground.value, variables)) : null;
228
+ const backgroundColor = background ? simpleBackground(resolveCssValue(background.value, variables)) : null;
192
229
  const ratio = foregroundColor && backgroundColor ? contrastRatio(foregroundColor, backgroundColor) : null;
193
230
  return ratio !== null && ratio < minimumTextContrast
194
- ? `[A11Y-06] ${relativePath(path)}:${rule.line} ${rule.selector} — ${ratio.toFixed(2)}:1 `
231
+ ? `[A11Y-06][${theme}] ${relativePath(path)}:${rule.line} ${rule.selector} — ${ratio.toFixed(2)}:1 `
195
232
  + `(mínimo ${minimumTextContrast}:1)`
196
233
  : null;
197
234
  }
@@ -205,18 +242,27 @@ function fontSizeFinding(path: string, rule: CssRule): string | null {
205
242
  + `(revisar por debajo de ${minimumReviewFontSizeRem}rem)`;
206
243
  }
207
244
 
208
- function inspectRule(path: string, rule: CssRule): { contrastFailures: string[]; fontSizeCandidates: string[] } {
209
- const contrast = contrastFinding(path, rule);
210
- const fontSize = fontSizeFinding(path, rule);
245
+ function inspectSource(path: string, source: string): { contrastFailures: string[]; fontSizeCandidates: string[] } {
246
+ const rules = parseRules(source);
247
+ const contrastFailures = themes.flatMap((theme) => {
248
+ const variables = collectVariables(rules, theme);
249
+ return rules
250
+ .filter((rule) => isActiveRule(rule.selector, theme))
251
+ .map((rule) => contrastFinding(path, rule, theme, variables))
252
+ .filter((finding): finding is string => finding !== null);
253
+ });
254
+ const fontSizeCandidates = rules
255
+ .map((rule) => fontSizeFinding(path, rule))
256
+ .filter((finding): finding is string => finding !== null);
211
257
  return {
212
- contrastFailures: contrast ? [contrast] : [],
213
- fontSizeCandidates: fontSize ? [fontSize] : [],
258
+ contrastFailures,
259
+ fontSizeCandidates,
214
260
  };
215
261
  }
216
262
 
217
263
  function inspectStyles(): { contrastFailures: string[]; fontSizeCandidates: string[] } {
218
264
  const findings = findFiles(toolRoot, ['.css', '.astro'])
219
- .flatMap((path) => cssSources(path).flatMap((source) => parseRules(source).map((rule) => inspectRule(path, rule))));
265
+ .flatMap((path) => cssSources(path).map((source) => inspectSource(path, source)));
220
266
  return {
221
267
  contrastFailures: findings.flatMap((finding) => finding.contrastFailures),
222
268
  fontSizeCandidates: findings.flatMap((finding) => finding.fontSizeCandidates),
@@ -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('eight tools are registered', () => {
6
- expect(ALL_TOOLS.length).toBe(8);
5
+ it('nine tools are registered', () => {
6
+ expect(ALL_TOOLS.length).toBe(9);
7
7
  });
8
8
  });
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ALL_ENTRIES } from '../entries';
5
+ import { ALL_TOOLS } from '../tools';
6
+
7
+ describe('runtime registry', () => {
8
+ it('keeps every runtime tool represented in the data registry', () => {
9
+ const toolIds = ALL_TOOLS.map(({ entry }) => entry.id).sort();
10
+ const entryIds = ALL_ENTRIES.map(({ id }) => id).sort();
11
+
12
+ expect(entryIds).toEqual(toolIds);
13
+ });
14
+
15
+ it('keeps every runtime directory represented in the data registry', () => {
16
+ const runtimeIds = readdirSync(join(process.cwd(), 'src', 'tool'), { withFileTypes: true })
17
+ .filter((directory) => directory.isDirectory())
18
+ .map((directory) => join(process.cwd(), 'src', 'tool', directory.name))
19
+ .filter((directory) => existsSync(join(directory, 'index.ts')) && existsSync(join(directory, 'entry.ts')))
20
+ .map((directory) => readFileSync(join(directory, 'entry.ts'), 'utf8').match(/\bid\s*:\s*['"]([^'"]+)['"]/)?.[1])
21
+ .filter((id): id is string => Boolean(id))
22
+ .sort();
23
+
24
+ const entryIds = ALL_ENTRIES.map(({ id }) => id).sort();
25
+ expect(entryIds).toEqual(runtimeIds);
26
+ });
27
+ });
@@ -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 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(8);
7
+ it('should have 9 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(9);
9
9
  });
10
10
 
11
11
  it('gamesCategory should be defined', () => {
@@ -6,6 +6,7 @@
6
6
  --ls-panel-soft: #f4f7fa;
7
7
  --ls-accent: #e56a47;
8
8
  --ls-accent-dark: #bc4d30;
9
+ --ls-on-accent: #17202f;
9
10
  --ls-cyan: #19a6a0;
10
11
  --ls-yellow: #e7a633;
11
12
  --ls-red: #ce4f61;
@@ -24,6 +25,7 @@
24
25
  --ls-panel-soft: #172232;
25
26
  --ls-accent: #f1845d;
26
27
  --ls-accent-dark: #da6744;
28
+ --ls-on-accent: #111926;
27
29
  --ls-cyan: #42c3b8;
28
30
  --ls-yellow: #efbd57;
29
31
  --ls-red: #f07889;
@@ -170,7 +172,7 @@
170
172
 
171
173
  .ls-drop-mark span:last-child {
172
174
  background: var(--ls-accent);
173
- color: var(--ls-panel);
175
+ color: var(--ls-on-accent);
174
176
  transform: translateX(0.4rem);
175
177
  }
176
178
 
@@ -219,7 +221,7 @@
219
221
 
220
222
  .ls-button-primary {
221
223
  background: var(--ls-accent);
222
- color: var(--ls-panel);
224
+ color: var(--ls-on-accent);
223
225
  }
224
226
 
225
227
  .ls-button-primary:hover:not(:disabled) {
@@ -0,0 +1,234 @@
1
+ import {
2
+ PRESET_PALETTES,
3
+ parsePaletteInput,
4
+ quantizeImageData,
5
+ type PaletteColor,
6
+ } from './logic';
7
+
8
+ interface SourceImageState {
9
+ name: string;
10
+ width: number;
11
+ height: number;
12
+ data: Uint8ClampedArray;
13
+ }
14
+
15
+ interface AppEls {
16
+ root: HTMLElement;
17
+ fileInput: HTMLInputElement;
18
+ chooseButton: HTMLButtonElement;
19
+ dropZone: HTMLElement;
20
+ sourceCanvas: HTMLCanvasElement;
21
+ resultCanvas: HTMLCanvasElement;
22
+ emptyState: HTMLElement;
23
+ canvasPair: HTMLElement;
24
+ customPaletteInput: HTMLTextAreaElement;
25
+ downloadButton: HTMLButtonElement;
26
+ zoomInput: HTMLInputElement;
27
+ zoomValue: HTMLOutputElement;
28
+ }
29
+
30
+ let initialized = false;
31
+
32
+ function getEl<T extends HTMLElement>(root: HTMLElement, sel: string): T | null {
33
+ return root.querySelector<T>(sel);
34
+ }
35
+
36
+ function queryAppElements(root: HTMLElement): AppEls | null {
37
+ const req = [
38
+ '#pas-file-input', '#pas-choose-image', '#pas-drop-zone',
39
+ '#pas-source-canvas', '#pas-result-canvas', '#pas-empty-state',
40
+ '#pas-canvas-pair', '#pas-custom-palette', '#pas-download',
41
+ '#pas-zoom', '#pas-zoom-value',
42
+ ];
43
+ const found = req.map((s) => root.querySelector(s));
44
+ if (found.some((el) => !el)) return null;
45
+
46
+ return {
47
+ root,
48
+ fileInput: found[0] as HTMLInputElement,
49
+ chooseButton: found[1] as HTMLButtonElement,
50
+ dropZone: found[2] as HTMLElement,
51
+ sourceCanvas: found[3] as HTMLCanvasElement,
52
+ resultCanvas: found[4] as HTMLCanvasElement,
53
+ emptyState: found[5] as HTMLElement,
54
+ canvasPair: found[6] as HTMLElement,
55
+ customPaletteInput: found[7] as HTMLTextAreaElement,
56
+ downloadButton: found[8] as HTMLButtonElement,
57
+ zoomInput: found[9] as HTMLInputElement,
58
+ zoomValue: found[10] as HTMLOutputElement,
59
+ };
60
+ }
61
+
62
+ function renderSwatches(root: HTMLElement): void {
63
+ root.querySelectorAll<HTMLElement>('[data-swatch-palette]').forEach((strip) => {
64
+ const id = strip.dataset.swatchPalette;
65
+ const palette = id && id in PRESET_PALETTES
66
+ ? PRESET_PALETTES[id as keyof typeof PRESET_PALETTES]
67
+ : [];
68
+ strip.replaceChildren(...palette.map((c) => {
69
+ const s = document.createElement('span');
70
+ s.style.backgroundColor = c.hex;
71
+ return s;
72
+ }));
73
+ });
74
+ }
75
+
76
+ function fitCanvas(canvas: HTMLCanvasElement, width: number, height: number, zoom: number): void {
77
+ const displayWidth = Math.min(620, Math.max(32, width * zoom));
78
+ const displayHeight = Math.max(32, Math.round(displayWidth * height / width));
79
+ canvas.style.width = `${displayWidth}px`;
80
+ canvas.style.height = `${displayHeight}px`;
81
+ }
82
+
83
+ interface CanvasDrawOptions {
84
+ canvas: HTMLCanvasElement;
85
+ width: number;
86
+ height: number;
87
+ data: Uint8ClampedArray;
88
+ zoom: number;
89
+ }
90
+
91
+ function drawCanvas(opts: CanvasDrawOptions): void {
92
+ opts.canvas.width = opts.width;
93
+ opts.canvas.height = opts.height;
94
+ const context = opts.canvas.getContext('2d');
95
+ if (!context) return;
96
+ context.imageSmoothingEnabled = false;
97
+ context.putImageData(new ImageData(new Uint8ClampedArray(opts.data), opts.width, opts.height), 0, 0);
98
+ fitCanvas(opts.canvas, opts.width, opts.height, opts.zoom);
99
+ }
100
+
101
+ function setStatus(root: HTMLElement, message: string, tone = 'idle'): void {
102
+ const statusEl = getEl<HTMLElement>(root, '#pas-status');
103
+ if (!statusEl) return;
104
+ statusEl.textContent = message;
105
+ statusEl.dataset.tone = tone;
106
+ }
107
+
108
+ function updateSummaryTexts(root: HTMLElement, q: { sourceColors: number; mappedColors: number }, width: number, height: number): void {
109
+ const scc = getEl<HTMLElement>(root, '#pas-source-colors');
110
+ const mcc = getEl<HTMLElement>(root, '#pas-mapped-colors');
111
+ const isz = getEl<HTMLElement>(root, '#pas-image-size');
112
+ const rn = getEl<HTMLElement>(root, '#pas-render-note');
113
+ if (scc) scc.textContent = String(q.sourceColors);
114
+ if (mcc) mcc.textContent = String(q.mappedColors);
115
+ if (isz) isz.textContent = `${width} × ${height}`;
116
+ if (rn) rn.textContent = (root.dataset.mappedSummary ?? '').replace('{source}', String(q.sourceColors)).replace('{mapped}', String(q.mappedColors));
117
+ }
118
+
119
+ function processImageLoad(els: AppEls, file: File, img: HTMLImageElement): SourceImageState | null {
120
+ const canvas = document.createElement('canvas');
121
+ canvas.width = img.naturalWidth;
122
+ canvas.height = img.naturalHeight;
123
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
124
+ if (!ctx) return null;
125
+ ctx.imageSmoothingEnabled = false;
126
+ ctx.drawImage(img, 0, 0);
127
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
128
+ const fn = getEl<HTMLElement>(els.root, '#pas-file-name');
129
+ if (fn) fn.textContent = file.name;
130
+ getEl<HTMLElement>(els.root, '.pas-command-bar')?.classList.add('has-image');
131
+ els.chooseButton.textContent = els.root.dataset.replaceImage ?? 'Replace image';
132
+ els.emptyState.hidden = true;
133
+ els.canvasPair.hidden = false;
134
+ return { name: file.name, width: canvas.width, height: canvas.height, data };
135
+ }
136
+
137
+ function setupFileHandler(els: AppEls, onLoaded: (src: SourceImageState) => void): void {
138
+ els.chooseButton.addEventListener('click', () => els.fileInput.click());
139
+ els.dropZone.addEventListener('click', () => els.fileInput.click());
140
+ els.fileInput.addEventListener('change', () => {
141
+ const file = els.fileInput.files?.[0];
142
+ if (!file || !file.type.startsWith('image/')) return;
143
+ const url = URL.createObjectURL(file);
144
+ const img = new Image();
145
+ img.onload = () => {
146
+ const src = processImageLoad(els, file, img);
147
+ if (src) onLoaded(src);
148
+ URL.revokeObjectURL(url);
149
+ };
150
+ img.src = url;
151
+ });
152
+ }
153
+
154
+ function setupPresetButtons(els: AppEls, onApply: (p: PaletteColor[], id: string) => void): void {
155
+ els.root.querySelectorAll<HTMLButtonElement>('[data-palette-id]').forEach((b) => b.addEventListener('click', () => {
156
+ const id = b.dataset.paletteId ?? 'gameBoy';
157
+ const palette = PRESET_PALETTES[id as keyof typeof PRESET_PALETTES];
158
+ if (!palette) return;
159
+ els.customPaletteInput.value = palette.map((c) => c.hex).join(', ');
160
+ onApply(palette, id);
161
+ }));
162
+ }
163
+
164
+ function setupCustomAndDownload(els: AppEls, onCustomApply: (hexList: string) => void, onCustomReset: () => void, onDownload: () => void): void {
165
+ getEl<HTMLButtonElement>(els.root, '#pas-apply-custom')?.addEventListener('click', () => onCustomApply(els.customPaletteInput.value));
166
+ getEl<HTMLButtonElement>(els.root, '#pas-reset-custom')?.addEventListener('click', () => onCustomReset());
167
+ els.downloadButton.addEventListener('click', onDownload);
168
+ }
169
+
170
+ function triggerDownload(canvas: HTMLCanvasElement, name: string, id: string): void {
171
+ canvas.toBlob((blob) => {
172
+ if (!blob) return;
173
+ const a = document.createElement('a');
174
+ a.download = `${name.replace(/\.[^.]+$/, '') || 'pixel-art'}-${id}.png`;
175
+ a.href = URL.createObjectURL(blob);
176
+ a.click();
177
+ }, 'image/png');
178
+ }
179
+
180
+ function runQuantizeRender(els: AppEls, src: SourceImageState, pal: PaletteColor[]): Uint8ClampedArray {
181
+ setStatus(els.root, els.root.dataset.processing ?? 'Mapping pixels', 'working');
182
+ const alpha = getEl<HTMLInputElement>(els.root, '#pas-preserve-alpha')?.checked ?? true;
183
+ const q = quantizeImageData(src.data, pal, alpha);
184
+ drawCanvas({ canvas: els.resultCanvas, width: src.width, height: src.height, data: q.data, zoom: Number(els.zoomInput.value) });
185
+ const ip = getEl<HTMLElement>(els.root, '#pas-inspector-preview');
186
+ if (ip) ip.style.backgroundImage = `url(${els.resultCanvas.toDataURL('image/png')})`;
187
+ updateSummaryTexts(els.root, q, src.width, src.height);
188
+ setStatus(els.root, els.root.dataset.ready ?? 'Ready', 'ready');
189
+ els.downloadButton.disabled = false;
190
+ return q.data;
191
+ }
192
+
193
+ function setupCoreAppLogic(els: AppEls): void {
194
+ let activePalette = PRESET_PALETTES.gameBoy;
195
+ let sourceImage: SourceImageState | null = null;
196
+ let resultData: Uint8ClampedArray | null = null;
197
+ let activePaletteId = 'gameBoy';
198
+
199
+ const renderResult = (): void => {
200
+ if (sourceImage) resultData = runQuantizeRender(els, sourceImage, activePalette);
201
+ };
202
+
203
+ const applyPalette = (palette: PaletteColor[], paletteId: string): void => {
204
+ if (palette.length === 0) return;
205
+ activePalette = palette;
206
+ activePaletteId = paletteId;
207
+ els.root.querySelectorAll<HTMLButtonElement>('[data-palette-id]').forEach((b) => b.classList.toggle('is-selected', b.dataset.paletteId === paletteId));
208
+ renderResult();
209
+ };
210
+
211
+ setupFileHandler(els, (src) => {
212
+ sourceImage = src;
213
+ drawCanvas({ canvas: els.sourceCanvas, width: src.width, height: src.height, data: src.data, zoom: Number(els.zoomInput.value) });
214
+ renderResult();
215
+ });
216
+ setupPresetButtons(els, applyPalette);
217
+ setupCustomAndDownload(els,
218
+ (val) => applyPalette(parsePaletteInput(val), 'custom'),
219
+ () => { els.customPaletteInput.value = PRESET_PALETTES.gameBoy.map((c) => c.hex).join(', '); applyPalette(PRESET_PALETTES.gameBoy, 'gameBoy'); },
220
+ () => { if (sourceImage && resultData) triggerDownload(els.resultCanvas, sourceImage.name, activePaletteId); }
221
+ );
222
+ }
223
+
224
+ export function initPixelArtPaletteSwapper(): void {
225
+ if (initialized) return;
226
+ initialized = true;
227
+ const root = getEl<HTMLElement>(document.body, '[data-tool="pixel-art-palette-swapper"]');
228
+ if (!root) return;
229
+ const els = queryAppElements(root);
230
+ if (!els) return;
231
+
232
+ setupCoreAppLogic(els);
233
+ renderSwatches(els.root);
234
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: 'MDN Canvas API',
6
+ url: 'https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API',
7
+ },
8
+ {
9
+ name: 'Lospec Palette List',
10
+ url: 'https://lospec.com/palette-list',
11
+ },
12
+ {
13
+ name: 'W3C Portable Network Graphics Specification',
14
+ url: 'https://www.w3.org/TR/png-3/',
15
+ },
16
+ ];
@@ -0,0 +1,153 @@
1
+ ---
2
+ import type { PixelArtPaletteSwapperUI } from './ui';
3
+ import './pixel-art-palette-swapper.css';
4
+
5
+ interface Props {
6
+ ui: PixelArtPaletteSwapperUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div
13
+ class="pas-shell"
14
+ data-tool="pixel-art-palette-swapper"
15
+ data-processing={ui.processing}
16
+ data-ready={ui.readyStatus}
17
+ data-invalid-palette={ui.invalidPalette}
18
+ data-invalid-image={ui.invalidImage}
19
+ data-mapped-summary={ui.mappedSummary}
20
+ data-replace-image={ui.replaceImage}
21
+ data-drop-active={ui.dropActive}
22
+ data-upload-title={ui.uploadTitle}
23
+ >
24
+ <div class="pas-command-bar">
25
+ <div class="pas-drop-zone" id="pas-drop-zone" tabindex="0" role="button" aria-label={ui.uploadTitle}>
26
+ <span class="pas-drop-mark" aria-hidden="true">+</span>
27
+ <span class="pas-drop-copy">
28
+ <strong id="pas-drop-title">{ui.uploadTitle}</strong>
29
+ <small id="pas-drop-hint">{ui.uploadHint}</small>
30
+ </span>
31
+ <input id="pas-file-input" class="pas-visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" aria-label={ui.chooseImage} />
32
+ </div>
33
+ <button id="pas-choose-image" class="pas-button pas-button-primary" type="button">{ui.chooseImage}</button>
34
+ <span id="pas-file-name" class="pas-file-name">{ui.waitingForImage}</span>
35
+ <span id="pas-status" class="pas-status" role="status" aria-live="polite">{ui.waitingForImage}</span>
36
+ </div>
37
+
38
+ <div class="pas-workbench">
39
+ <aside class="pas-panel pas-palette-panel">
40
+ <div class="pas-panel-heading">
41
+ <span class="pas-kicker">01</span>
42
+ <h2>{ui.paletteTitle}</h2>
43
+ </div>
44
+
45
+ <div class="pas-preset-grid" role="list" aria-label={ui.paletteTitle}>
46
+ <button class="pas-preset is-selected" type="button" data-palette-id="gameBoy" role="listitem">
47
+ <span class="pas-preset-name">{ui.gameBoyPalette}</span>
48
+ <span class="pas-swatch-strip" data-swatch-palette="gameBoy" aria-hidden="true"></span>
49
+ </button>
50
+ <button class="pas-preset" type="button" data-palette-id="nes" role="listitem">
51
+ <span class="pas-preset-name">{ui.nesPalette}</span>
52
+ <span class="pas-swatch-strip" data-swatch-palette="nes" aria-hidden="true"></span>
53
+ </button>
54
+ <button class="pas-preset" type="button" data-palette-id="pico8" role="listitem">
55
+ <span class="pas-preset-name">{ui.pico8Palette}</span>
56
+ <span class="pas-swatch-strip" data-swatch-palette="pico8" aria-hidden="true"></span>
57
+ </button>
58
+ <button class="pas-preset" type="button" data-palette-id="commodore64" role="listitem">
59
+ <span class="pas-preset-name">{ui.commodore64Palette}</span>
60
+ <span class="pas-swatch-strip" data-swatch-palette="commodore64" aria-hidden="true"></span>
61
+ </button>
62
+ <button class="pas-preset" type="button" data-palette-id="dawnBringer16" role="listitem">
63
+ <span class="pas-preset-name">{ui.dawnBringerPalette}</span>
64
+ <span class="pas-swatch-strip" data-swatch-palette="dawnBringer16" aria-hidden="true"></span>
65
+ </button>
66
+ </div>
67
+
68
+ <div class="pas-custom-block">
69
+ <div class="pas-field-heading">
70
+ <label for="pas-custom-palette">{ui.customPalette}</label>
71
+ <span id="pas-palette-count">4 {ui.paletteCount}</span>
72
+ </div>
73
+ <textarea id="pas-custom-palette" rows="3" spellcheck="false" aria-describedby="pas-custom-hint">#0f380f, #306230, #8bac0f, #9bbc0f</textarea>
74
+ <small id="pas-custom-hint">{ui.customPaletteHint}</small>
75
+ <div class="pas-inline-actions">
76
+ <button id="pas-apply-custom" class="pas-button pas-button-dark" type="button">{ui.applyCustomPalette}</button>
77
+ <button id="pas-reset-custom" class="pas-text-button" type="button">{ui.resetCustomPalette}</button>
78
+ </div>
79
+ </div>
80
+
81
+ <label class="pas-toggle-row" for="pas-preserve-alpha">
82
+ <span>{ui.preserveAlpha}</span>
83
+ <input id="pas-preserve-alpha" type="checkbox" checked />
84
+ <span class="pas-toggle" aria-hidden="true"></span>
85
+ </label>
86
+ </aside>
87
+
88
+ <main class="pas-preview-panel">
89
+ <div class="pas-panel-heading pas-preview-heading">
90
+ <div>
91
+ <span class="pas-kicker">02</span>
92
+ <h2>{ui.sourcePreview} <span aria-hidden="true">→</span> {ui.resultPreview}</h2>
93
+ </div>
94
+ <label class="pas-zoom-control" for="pas-zoom">
95
+ <span>{ui.zoomLabel}</span>
96
+ <input id="pas-zoom" type="range" min="1" max="8" value="3" />
97
+ <output id="pas-zoom-value" for="pas-zoom">3x</output>
98
+ </label>
99
+ </div>
100
+
101
+ <div class="pas-canvas-stage" id="pas-canvas-stage">
102
+ <div class="pas-empty-state" id="pas-empty-state">
103
+ <div class="pas-empty-pixel" aria-hidden="true"></div>
104
+ <strong>{ui.uploadToPreview}</strong>
105
+ <span>{ui.resultEmpty}</span>
106
+ </div>
107
+ <div class="pas-canvas-pair" id="pas-canvas-pair" hidden>
108
+ <figure class="pas-canvas-card">
109
+ <figcaption><span class="pas-dot pas-dot-source"></span>{ui.sourcePreview}</figcaption>
110
+ <canvas id="pas-source-canvas" width="1" height="1" aria-label={ui.sourcePreview}></canvas>
111
+ </figure>
112
+ <figure class="pas-canvas-card pas-result-card">
113
+ <figcaption><span class="pas-dot pas-dot-result"></span>{ui.resultPreview}</figcaption>
114
+ <canvas id="pas-result-canvas" width="1" height="1" aria-label={ui.resultPreview}></canvas>
115
+ </figure>
116
+ </div>
117
+ </div>
118
+
119
+ <div class="pas-preview-footer">
120
+ <span class="pas-shortcut">PNG <span aria-hidden="true">·</span> RGB nearest-color mapping</span>
121
+ <span id="pas-render-note">{ui.waitingForImage}</span>
122
+ </div>
123
+ </main>
124
+
125
+ <aside class="pas-panel pas-inspector-panel">
126
+ <div class="pas-panel-heading">
127
+ <span class="pas-kicker">03</span>
128
+ <h2>{ui.resultPreview}</h2>
129
+ </div>
130
+ <div class="pas-inspector-preview" id="pas-inspector-preview" aria-hidden="true">
131
+ <div class="pas-inspector-pixel-grid"></div>
132
+ </div>
133
+ <div class="pas-stats-list">
134
+ <div><span>{ui.imageSize}</span><strong id="pas-image-size">—</strong></div>
135
+ <div><span>{ui.colorCount}</span><strong id="pas-source-colors">—</strong></div>
136
+ <div><span>{ui.mappedCount}</span><strong id="pas-mapped-colors">—</strong></div>
137
+ <div><span>{ui.paletteCount}</span><strong id="pas-inspector-palette-count">4</strong></div>
138
+ </div>
139
+ <button id="pas-download" class="pas-button pas-download-button" type="button" disabled>{ui.downloadPng}</button>
140
+ <span id="pas-download-hint" class="pas-download-hint">{ui.downloadDisabled}</span>
141
+ </aside>
142
+ </div>
143
+ </div>
144
+
145
+ <script>
146
+ import { initPixelArtPaletteSwapper } from './app-client';
147
+
148
+ if (document.readyState === 'loading') {
149
+ document.addEventListener('DOMContentLoaded', initPixelArtPaletteSwapper, { once: true });
150
+ } else {
151
+ initPixelArtPaletteSwapper();
152
+ }
153
+ </script>
@@ -0,0 +1,28 @@
1
+ import type { GamesToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { PixelArtPaletteSwapperUI } from './ui';
3
+
4
+ export type { PixelArtPaletteSwapperUI };
5
+
6
+ export type PixelArtPaletteSwapperLocaleContent = ToolLocaleContent<PixelArtPaletteSwapperUI>;
7
+
8
+ export const pixelArtPaletteSwapper: GamesToolEntry<PixelArtPaletteSwapperUI> = {
9
+ id: 'pixel-art-palette-swapper',
10
+ icons: { bg: 'mdi:palette', fg: 'mdi:format-color-fill' },
11
+ i18n: {
12
+ de: () => import('./i18n/de').then((module) => module.content),
13
+ en: () => import('./i18n/en').then((module) => module.content),
14
+ es: () => import('./i18n/es').then((module) => module.content),
15
+ fr: () => import('./i18n/fr').then((module) => module.content),
16
+ id: () => import('./i18n/id').then((module) => module.content),
17
+ it: () => import('./i18n/it').then((module) => module.content),
18
+ ja: () => import('./i18n/ja').then((module) => module.content),
19
+ ko: () => import('./i18n/ko').then((module) => module.content),
20
+ nl: () => import('./i18n/nl').then((module) => module.content),
21
+ pl: () => import('./i18n/pl').then((module) => module.content),
22
+ pt: () => import('./i18n/pt').then((module) => module.content),
23
+ ru: () => import('./i18n/ru').then((module) => module.content),
24
+ sv: () => import('./i18n/sv').then((module) => module.content),
25
+ tr: () => import('./i18n/tr').then((module) => module.content),
26
+ zh: () => import('./i18n/zh').then((module) => module.content),
27
+ },
28
+ };