@jjlmoya/utils-tabletop 1.4.0 → 1.5.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 (123) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +11 -1
  3. package/src/index.ts +1 -0
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +1 -1
  6. package/src/tool/decision-wheel/audio.ts +78 -0
  7. package/src/tool/decision-wheel/bibliography.astro +16 -0
  8. package/src/tool/decision-wheel/bibliography.ts +8 -0
  9. package/src/tool/decision-wheel/client.ts +226 -0
  10. package/src/tool/decision-wheel/component.astro +48 -0
  11. package/src/tool/decision-wheel/components/SegmentEditor.astro +22 -0
  12. package/src/tool/decision-wheel/components/SpinHistory.astro +17 -0
  13. package/src/tool/decision-wheel/components/SpinResult.astro +13 -0
  14. package/src/tool/decision-wheel/components/WheelCanvas.astro +23 -0
  15. package/src/tool/decision-wheel/decision-wheel.css +632 -0
  16. package/src/tool/decision-wheel/drawing.ts +144 -0
  17. package/src/tool/decision-wheel/entry.ts +48 -0
  18. package/src/tool/decision-wheel/i18n/de.ts +97 -0
  19. package/src/tool/decision-wheel/i18n/en.ts +251 -0
  20. package/src/tool/decision-wheel/i18n/es.ts +93 -0
  21. package/src/tool/decision-wheel/i18n/fr.ts +93 -0
  22. package/src/tool/decision-wheel/i18n/id.ts +93 -0
  23. package/src/tool/decision-wheel/i18n/it.ts +93 -0
  24. package/src/tool/decision-wheel/i18n/ja.ts +93 -0
  25. package/src/tool/decision-wheel/i18n/ko.ts +93 -0
  26. package/src/tool/decision-wheel/i18n/nl.ts +93 -0
  27. package/src/tool/decision-wheel/i18n/pl.ts +93 -0
  28. package/src/tool/decision-wheel/i18n/pt.ts +93 -0
  29. package/src/tool/decision-wheel/i18n/ru.ts +75 -0
  30. package/src/tool/decision-wheel/i18n/sv.ts +93 -0
  31. package/src/tool/decision-wheel/i18n/tr.ts +93 -0
  32. package/src/tool/decision-wheel/i18n/zh.ts +93 -0
  33. package/src/tool/decision-wheel/index.ts +9 -0
  34. package/src/tool/decision-wheel/logic.test.ts +163 -0
  35. package/src/tool/decision-wheel/logic.ts +145 -0
  36. package/src/tool/decision-wheel/seo.astro +16 -0
  37. package/src/tool/decision-wheel/types.ts +14 -0
  38. package/src/tool/decision-wheel/ui.ts +121 -0
  39. package/src/tool/fantasy-runes-translator/bibliography.astro +16 -0
  40. package/src/tool/fantasy-runes-translator/bibliography.ts +16 -0
  41. package/src/tool/fantasy-runes-translator/client.ts +205 -0
  42. package/src/tool/fantasy-runes-translator/component.astro +95 -0
  43. package/src/tool/fantasy-runes-translator/entry.ts +56 -0
  44. package/src/tool/fantasy-runes-translator/fantasy-runes-translator.css +712 -0
  45. package/src/tool/fantasy-runes-translator/i18n/de.ts +139 -0
  46. package/src/tool/fantasy-runes-translator/i18n/en.ts +139 -0
  47. package/src/tool/fantasy-runes-translator/i18n/es.ts +139 -0
  48. package/src/tool/fantasy-runes-translator/i18n/fr.ts +139 -0
  49. package/src/tool/fantasy-runes-translator/i18n/id.ts +139 -0
  50. package/src/tool/fantasy-runes-translator/i18n/it.ts +139 -0
  51. package/src/tool/fantasy-runes-translator/i18n/ja.ts +139 -0
  52. package/src/tool/fantasy-runes-translator/i18n/ko.ts +139 -0
  53. package/src/tool/fantasy-runes-translator/i18n/nl.ts +139 -0
  54. package/src/tool/fantasy-runes-translator/i18n/pl.ts +139 -0
  55. package/src/tool/fantasy-runes-translator/i18n/pt.ts +139 -0
  56. package/src/tool/fantasy-runes-translator/i18n/ru.ts +139 -0
  57. package/src/tool/fantasy-runes-translator/i18n/sv.ts +139 -0
  58. package/src/tool/fantasy-runes-translator/i18n/tr.ts +139 -0
  59. package/src/tool/fantasy-runes-translator/i18n/zh.ts +139 -0
  60. package/src/tool/fantasy-runes-translator/index.ts +9 -0
  61. package/src/tool/fantasy-runes-translator/logic.ts +174 -0
  62. package/src/tool/fantasy-runes-translator/seo.astro +16 -0
  63. package/src/tool/fantasy-runes-translator/types.ts +26 -0
  64. package/src/tool/rpg-initiative-tracker/bibliography.astro +16 -0
  65. package/src/tool/rpg-initiative-tracker/bibliography.ts +12 -0
  66. package/src/tool/rpg-initiative-tracker/client.ts +201 -0
  67. package/src/tool/rpg-initiative-tracker/component.astro +23 -0
  68. package/src/tool/rpg-initiative-tracker/components/AddForm.astro +49 -0
  69. package/src/tool/rpg-initiative-tracker/components/CombatantList.astro +16 -0
  70. package/src/tool/rpg-initiative-tracker/components/TurnControls.astro +28 -0
  71. package/src/tool/rpg-initiative-tracker/entry.ts +50 -0
  72. package/src/tool/rpg-initiative-tracker/i18n/de.ts +266 -0
  73. package/src/tool/rpg-initiative-tracker/i18n/en.ts +263 -0
  74. package/src/tool/rpg-initiative-tracker/i18n/es.ts +264 -0
  75. package/src/tool/rpg-initiative-tracker/i18n/fr.ts +263 -0
  76. package/src/tool/rpg-initiative-tracker/i18n/id.ts +263 -0
  77. package/src/tool/rpg-initiative-tracker/i18n/it.ts +263 -0
  78. package/src/tool/rpg-initiative-tracker/i18n/ja.ts +263 -0
  79. package/src/tool/rpg-initiative-tracker/i18n/ko.ts +263 -0
  80. package/src/tool/rpg-initiative-tracker/i18n/nl.ts +263 -0
  81. package/src/tool/rpg-initiative-tracker/i18n/pl.ts +264 -0
  82. package/src/tool/rpg-initiative-tracker/i18n/pt.ts +263 -0
  83. package/src/tool/rpg-initiative-tracker/i18n/ru.ts +264 -0
  84. package/src/tool/rpg-initiative-tracker/i18n/sv.ts +263 -0
  85. package/src/tool/rpg-initiative-tracker/i18n/tr.ts +263 -0
  86. package/src/tool/rpg-initiative-tracker/i18n/zh.ts +263 -0
  87. package/src/tool/rpg-initiative-tracker/index.ts +9 -0
  88. package/src/tool/rpg-initiative-tracker/logic.test.ts +121 -0
  89. package/src/tool/rpg-initiative-tracker/logic.ts +122 -0
  90. package/src/tool/rpg-initiative-tracker/rpg-initiative-tracker.css +617 -0
  91. package/src/tool/rpg-initiative-tracker/seo.astro +16 -0
  92. package/src/tool/rpg-initiative-tracker/types.ts +15 -0
  93. package/src/tool/score-tracker/bibliography.astro +16 -0
  94. package/src/tool/score-tracker/bibliography.ts +12 -0
  95. package/src/tool/score-tracker/client.ts +248 -0
  96. package/src/tool/score-tracker/component.astro +34 -0
  97. package/src/tool/score-tracker/components/Leaderboard.astro +19 -0
  98. package/src/tool/score-tracker/components/PlayerSetup.astro +32 -0
  99. package/src/tool/score-tracker/components/RoundScoring.astro +22 -0
  100. package/src/tool/score-tracker/components/ScoreHistory.astro +25 -0
  101. package/src/tool/score-tracker/entry.ts +57 -0
  102. package/src/tool/score-tracker/i18n/de.ts +278 -0
  103. package/src/tool/score-tracker/i18n/en.ts +278 -0
  104. package/src/tool/score-tracker/i18n/es.ts +251 -0
  105. package/src/tool/score-tracker/i18n/fr.ts +278 -0
  106. package/src/tool/score-tracker/i18n/id.ts +278 -0
  107. package/src/tool/score-tracker/i18n/it.ts +278 -0
  108. package/src/tool/score-tracker/i18n/ja.ts +278 -0
  109. package/src/tool/score-tracker/i18n/ko.ts +278 -0
  110. package/src/tool/score-tracker/i18n/nl.ts +278 -0
  111. package/src/tool/score-tracker/i18n/pl.ts +278 -0
  112. package/src/tool/score-tracker/i18n/pt.ts +278 -0
  113. package/src/tool/score-tracker/i18n/ru.ts +278 -0
  114. package/src/tool/score-tracker/i18n/sv.ts +278 -0
  115. package/src/tool/score-tracker/i18n/tr.ts +278 -0
  116. package/src/tool/score-tracker/i18n/zh.ts +278 -0
  117. package/src/tool/score-tracker/index.ts +9 -0
  118. package/src/tool/score-tracker/logic.test.ts +83 -0
  119. package/src/tool/score-tracker/logic.ts +122 -0
  120. package/src/tool/score-tracker/score-tracker.css +1029 -0
  121. package/src/tool/score-tracker/seo.astro +16 -0
  122. package/src/tool/score-tracker/types.ts +17 -0
  123. package/src/tools.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-tabletop",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -4,10 +4,20 @@ export { boardGameTimer } from './tool/board-game-timer/entry';
4
4
  export type { BoardGameTimerUI, BoardGameTimerLocaleContent } from './tool/board-game-timer/entry';
5
5
  export { firstPlayerSelector } from './tool/first-player-selector/entry';
6
6
  export type { FirstPlayerSelectorUI, FirstPlayerSelectorLocaleContent } from './tool/first-player-selector/entry';
7
+ export { scoreTracker } from './tool/score-tracker/entry';
8
+ export type { ScoreTrackerUI, ScoreTrackerLocaleContent } from './tool/score-tracker/entry';
9
+ export { initiativeTracker } from './tool/rpg-initiative-tracker/entry';
10
+ export type { InitiativeTrackerUI, InitiativeTrackerLocaleContent } from './tool/rpg-initiative-tracker/entry';
11
+ export { fantasyRunesTranslator } from './tool/fantasy-runes-translator/entry';
12
+ export type { FantasyRunesTranslatorUI, FantasyRunesTranslatorLocaleContent } from './tool/fantasy-runes-translator/entry';
7
13
  export { tabletopCategory } from './category';
8
14
 
9
15
  import { diceRollerSimulator } from './tool/dice-roller-simulator/entry';
10
16
  import { boardGameTimer } from './tool/board-game-timer/entry';
11
17
  import { firstPlayerSelector } from './tool/first-player-selector/entry';
18
+ import { scoreTracker } from './tool/score-tracker/entry';
19
+ import { initiativeTracker } from './tool/rpg-initiative-tracker/entry';
20
+ import { fantasyRunesTranslator } from './tool/fantasy-runes-translator/entry';
21
+ import { decisionWheel } from './tool/decision-wheel/entry';
12
22
 
13
- export const ALL_ENTRIES = [diceRollerSimulator, boardGameTimer, firstPlayerSelector];
23
+ export const ALL_ENTRIES = [diceRollerSimulator, boardGameTimer, firstPlayerSelector, scoreTracker, initiativeTracker, fantasyRunesTranslator, decisionWheel];
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export const tabletopCategorySEO = () => import('./category/TabletopCategorySEO.
3
3
 
4
4
  export { diceRollerSimulator, DICE_ROLLER_SIMULATOR_TOOL } from './tool/dice-roller-simulator';
5
5
  export { boardGameTimer, BOARD_GAME_TIMER_TOOL } from './tool/board-game-timer';
6
+ export { fantasyRunesTranslator, FANTASY_RUNES_TRANSLATOR_TOOL } from './tool/fantasy-runes-translator';
6
7
 
7
8
  export type {
8
9
  KnownLocale,
@@ -22,7 +22,7 @@ describe('Locale Completeness Validation', () => {
22
22
  });
23
23
 
24
24
  it('all tools registered', () => {
25
- expect(ALL_TOOLS.length).toBe(3);
25
+ expect(ALL_TOOLS.length).toBe(7);
26
26
  });
27
27
 
28
28
  });
@@ -5,7 +5,7 @@ import { tabletopCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(3);
8
+ expect(ALL_TOOLS.length).toBe(7);
9
9
  });
10
10
 
11
11
  it('tabletopCategory should be defined', () => {
@@ -0,0 +1,78 @@
1
+ export class SoundEngine {
2
+ private ctx: AudioContext | null = null;
3
+ private muted = false;
4
+
5
+ private initCtx(): void {
6
+ if (this.ctx) return;
7
+ const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
8
+ if (AudioContextClass) {
9
+ this.ctx = new AudioContextClass();
10
+ }
11
+ }
12
+
13
+ public setMuted(muted: boolean): void {
14
+ this.muted = muted;
15
+ }
16
+
17
+ public isMuted(): boolean {
18
+ return this.muted;
19
+ }
20
+
21
+ public playTick(): void {
22
+ if (this.muted) return;
23
+ this.initCtx();
24
+ if (!this.ctx) return;
25
+
26
+ if (this.ctx.state === 'suspended') {
27
+ this.ctx.resume();
28
+ }
29
+
30
+ const osc = this.ctx.createOscillator();
31
+ const gain = this.ctx.createGain();
32
+
33
+ osc.connect(gain);
34
+ gain.connect(this.ctx.destination);
35
+
36
+ osc.type = 'sine';
37
+ osc.frequency.setValueAtTime(800, this.ctx.currentTime);
38
+ osc.frequency.exponentialRampToValueAtTime(100, this.ctx.currentTime + 0.05);
39
+
40
+ gain.gain.setValueAtTime(0.1, this.ctx.currentTime);
41
+ gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.05);
42
+
43
+ osc.start();
44
+ osc.stop(this.ctx.currentTime + 0.05);
45
+ }
46
+
47
+ public playChime(): void {
48
+ if (this.muted) return;
49
+ this.initCtx();
50
+ if (!this.ctx) return;
51
+
52
+ if (this.ctx.state === 'suspended') {
53
+ this.ctx.resume();
54
+ }
55
+
56
+ const notes = [523.25, 659.25, 783.99, 1046.50];
57
+ const now = this.ctx.currentTime;
58
+
59
+ notes.forEach((freq, idx) => {
60
+ if (!this.ctx) return;
61
+ const osc = this.ctx.createOscillator();
62
+ const gain = this.ctx.createGain();
63
+
64
+ osc.connect(gain);
65
+ gain.connect(this.ctx.destination);
66
+
67
+ osc.type = 'triangle';
68
+ osc.frequency.setValueAtTime(freq, now + idx * 0.08);
69
+
70
+ gain.gain.setValueAtTime(0, now + idx * 0.08);
71
+ gain.gain.linearRampToValueAtTime(0.15, now + idx * 0.08 + 0.02);
72
+ gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.08 + 0.3);
73
+
74
+ osc.start(now + idx * 0.08);
75
+ osc.stop(now + idx * 0.08 + 0.35);
76
+ });
77
+ }
78
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { decisionWheel } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props as Props;
11
+ const loader = decisionWheel.i18n[locale] || decisionWheel.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,8 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Decision Wheel Spinner Guide',
6
+ url: 'https://en.wikipedia.org/wiki/Wheel_of_fortune_(game_show)',
7
+ },
8
+ ];
@@ -0,0 +1,226 @@
1
+ import type { WheelSegment, SpinResultEntry, PresetKey } from './types';
2
+ import {
3
+ getPresetSegments,
4
+ addSegment as addSegmentLogic,
5
+ removeSegment as removeSegmentLogic,
6
+ updateSegment as updateSegmentLogic,
7
+ selectRandomSegment,
8
+ getSelectedIndex,
9
+ addHistoryItem,
10
+ } from './logic';
11
+ import { SoundEngine } from './audio';
12
+ import { drawWheel } from './drawing';
13
+ import { bindSegmentEvents, renderSegmentEditor, renderResult, triggerEmphasis, renderHistory } from './ui';
14
+
15
+ let segments = getPresetSegments('yesno');
16
+ let history: SpinResultEntry[] = [];
17
+ let isSpinning = false;
18
+ let currentAngle = 0;
19
+
20
+ const MAX_SEGMENTS = 16;
21
+ const soundEngine = new SoundEngine();
22
+
23
+ const canvas = document.getElementById('wheel-canvas') as HTMLCanvasElement;
24
+ const spinBtn = document.getElementById('spin-btn') as HTMLButtonElement;
25
+ const resultDisplay = document.getElementById('result-display') as HTMLElement;
26
+ const historyList = document.getElementById('history-list') as HTMLElement;
27
+ const historyEmpty = document.getElementById('history-empty') as HTMLElement;
28
+ const clearBtn = document.getElementById('clear-history-btn') as HTMLButtonElement;
29
+ const segmentList = document.getElementById('segment-list') as HTMLElement;
30
+ const addBtn = document.getElementById('add-segment-btn') as HTMLButtonElement;
31
+ const mainCard = document.querySelector('.tool-main-card') as HTMLElement;
32
+ const pointerEl = document.querySelector('.wheel-pointer') as HTMLElement;
33
+ const muteBtn = document.getElementById('mute-btn') as HTMLButtonElement;
34
+
35
+ if (muteBtn) {
36
+ const onSvg = muteBtn.querySelector('.sound-on-svg') as HTMLElement;
37
+ const offSvg = muteBtn.querySelector('.sound-off-svg') as HTMLElement;
38
+ muteBtn.addEventListener('click', () => {
39
+ const isMuted = !soundEngine.isMuted();
40
+ soundEngine.setMuted(isMuted);
41
+ if (onSvg && offSvg) {
42
+ onSvg.style.display = isMuted ? 'none' : 'block';
43
+ offSvg.style.display = isMuted ? 'block' : 'none';
44
+ }
45
+ muteBtn.setAttribute('aria-label', isMuted ? 'Unmute Sounds' : 'Mute Sounds');
46
+ });
47
+ }
48
+
49
+ function t(key: string): string {
50
+ if (!mainCard) return '';
51
+ try {
52
+ const ui = JSON.parse(mainCard.getAttribute('data-ui') || '{}');
53
+ return ui[key] || '';
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ interface SpinState {
60
+ startTime: number;
61
+ duration: number;
62
+ startAngle: number;
63
+ totalRotation: number;
64
+ lastIndex: number;
65
+ resolve: () => void;
66
+ }
67
+
68
+ function runSpinAnimation(now: number, state: SpinState): void {
69
+ const elapsed = now - state.startTime;
70
+ const progress = Math.min(elapsed / state.duration, 1);
71
+ const eased = 1 - Math.pow(1 - progress, 4);
72
+ currentAngle = state.startAngle + state.totalRotation * eased;
73
+ drawWheel(canvas, segments, currentAngle);
74
+
75
+ const currentIndex = getSelectedIndex(segments, currentAngle);
76
+ if (currentIndex !== state.lastIndex) {
77
+ soundEngine.playTick();
78
+ if (pointerEl) {
79
+ pointerEl.classList.add('tick-active');
80
+ setTimeout(() => {
81
+ pointerEl.classList.remove('tick-active');
82
+ }, 40);
83
+ }
84
+ state.lastIndex = currentIndex;
85
+ }
86
+
87
+ if (progress < 1) {
88
+ requestAnimationFrame((t) => runSpinAnimation(t, state));
89
+ } else {
90
+ currentAngle = state.startAngle + state.totalRotation;
91
+ drawWheel(canvas, segments, currentAngle);
92
+ state.resolve();
93
+ }
94
+ }
95
+
96
+ function animateSpin(targetIndex: number): Promise<void> {
97
+ return new Promise((resolve) => {
98
+ const totalWeight = segments.reduce((sum, s) => sum + (s.weight || 1), 0);
99
+ let acc = 0;
100
+ for (let i = 0; i < targetIndex; i++) {
101
+ acc += segments[i].weight || 1;
102
+ }
103
+ const targetSliceWeight = segments[targetIndex].weight || 1;
104
+ const startAngleSeg = (acc / totalWeight) * 360;
105
+ const endAngleSeg = ((acc + targetSliceWeight) / totalWeight) * 360;
106
+ const targetAngle = startAngleSeg + (endAngleSeg - startAngleSeg) / 2;
107
+
108
+ const fullSpins = 4 + Math.floor(Math.random() * 3);
109
+ const totalRotation = fullSpins * 360 + (360 - targetAngle);
110
+ const duration = 2500 + fullSpins * 300;
111
+
112
+ const state: SpinState = {
113
+ startTime: performance.now(),
114
+ duration,
115
+ startAngle: currentAngle,
116
+ totalRotation,
117
+ lastIndex: getSelectedIndex(segments, currentAngle),
118
+ resolve,
119
+ };
120
+
121
+ requestAnimationFrame((t) => runSpinAnimation(t, state));
122
+ });
123
+ }
124
+
125
+ function setSegmentsWithReset(newSegments: WheelSegment[]): void {
126
+ segments = newSegments;
127
+ currentAngle = 0;
128
+ drawWheel(canvas, segments, currentAngle);
129
+ renderSegmentEditor(segmentList, segments, t, bindAll);
130
+ renderResult(resultDisplay, null, false, t);
131
+ }
132
+
133
+ function handleUpdateSegment(id: string, field: string, value: string): void {
134
+ segments = updateSegmentLogic(segments, id, field, value);
135
+ if (field === 'color' || field === 'weight') {
136
+ drawWheel(canvas, segments, currentAngle);
137
+ }
138
+ }
139
+
140
+ function handleRemoveSegment(id: string): void {
141
+ segments = removeSegmentLogic(segments, id);
142
+ renderSegmentEditor(segmentList, segments, t, bindAll);
143
+ drawWheel(canvas, segments, currentAngle);
144
+ }
145
+
146
+ function bindAll(): void {
147
+ bindSegmentEvents(segmentList, handleUpdateSegment, handleRemoveSegment);
148
+ }
149
+
150
+ async function onSpin(): Promise<void> {
151
+ if (isSpinning) return;
152
+ if (segments.length < 2) return;
153
+ isSpinning = true;
154
+ spinBtn.disabled = true;
155
+ renderResult(resultDisplay, null, true, t);
156
+
157
+ const canvasCard = canvas.closest('.canvas-card') as HTMLElement;
158
+ if (canvasCard) {
159
+ canvasCard.classList.add('spinning');
160
+ }
161
+
162
+ const targetIndex = selectRandomSegment(segments);
163
+ await animateSpin(targetIndex);
164
+
165
+ const resultSegment = segments[targetIndex];
166
+ history = addHistoryItem(history, resultSegment, targetIndex);
167
+
168
+ soundEngine.playChime();
169
+
170
+ if (canvasCard) {
171
+ canvasCard.classList.remove('spinning');
172
+ }
173
+
174
+ isSpinning = false;
175
+ spinBtn.disabled = false;
176
+ renderResult(resultDisplay, { segment: resultSegment, index: targetIndex, timestamp: Date.now() }, false, t);
177
+ renderHistory(historyList, historyEmpty, history);
178
+ triggerEmphasis();
179
+ }
180
+
181
+ spinBtn.addEventListener('click', onSpin);
182
+
183
+ clearBtn.addEventListener('click', () => {
184
+ history = [];
185
+ renderHistory(historyList, historyEmpty, history);
186
+ });
187
+
188
+ addBtn.addEventListener('click', () => {
189
+ if (segments.length >= MAX_SEGMENTS) return;
190
+ segments = addSegmentLogic(segments);
191
+ renderSegmentEditor(segmentList, segments, t, bindAll);
192
+ drawWheel(canvas, segments, currentAngle);
193
+ });
194
+
195
+ document.querySelectorAll('.preset-btn').forEach((btn) => {
196
+ btn.addEventListener('click', (e) => {
197
+ const el = e.currentTarget as HTMLButtonElement;
198
+ const key = el.dataset.preset as PresetKey;
199
+ loadPreset(key);
200
+ });
201
+ });
202
+
203
+ function loadPreset(key: PresetKey): void {
204
+ const newSegments = getPresetSegments(key);
205
+ setSegmentsWithReset(newSegments);
206
+ }
207
+
208
+ document.querySelectorAll('.tab-header').forEach((btn) => {
209
+ btn.addEventListener('click', (e) => {
210
+ const target = e.currentTarget as HTMLButtonElement;
211
+ const tabName = target.dataset.tab;
212
+
213
+ document.querySelectorAll('.tab-header').forEach((h) => h.classList.remove('active'));
214
+ document.querySelectorAll('.tab-pane').forEach((p) => p.classList.remove('active'));
215
+
216
+ target.classList.add('active');
217
+ const pane = document.getElementById(`pane-${tabName}`);
218
+ if (pane) {
219
+ pane.classList.add('active');
220
+ }
221
+ });
222
+ });
223
+
224
+ drawWheel(canvas, segments, currentAngle);
225
+ renderSegmentEditor(segmentList, segments, t, bindAll);
226
+ renderHistory(historyList, historyEmpty, history);
@@ -0,0 +1,48 @@
1
+ ---
2
+ import WheelCanvas from './components/WheelCanvas.astro';
3
+ import SegmentEditor from './components/SegmentEditor.astro';
4
+ import SpinResult from './components/SpinResult.astro';
5
+ import SpinHistory from './components/SpinHistory.astro';
6
+
7
+ interface Props {
8
+ ui: Record<string, string>;
9
+ }
10
+
11
+ const { ui } = Astro.props;
12
+ ---
13
+
14
+ <div class="tool-main-card" data-ui={JSON.stringify(ui)}>
15
+ <div class="wheel-dashboard">
16
+ <div class="wheel-layout">
17
+
18
+ <div class="wheel-canvas-section">
19
+ <div class="canvas-card">
20
+ <WheelCanvas labels={ui} />
21
+ <div class="wheel-pointer" aria-hidden="true"></div>
22
+ </div>
23
+ <button type="button" id="spin-btn" class="spin-button">{ui.spinButton || 'Spin the Wheel'}</button>
24
+ <SpinResult labels={ui} />
25
+ </div>
26
+
27
+ <div class="wheel-controls-section">
28
+ <div class="tab-container">
29
+ <div class="tab-headers">
30
+ <button type="button" class="tab-header active" data-tab="editor">{ui.presetLabel || 'Customize'}</button>
31
+ <button type="button" class="tab-header" data-tab="history">{ui.historyTitle || 'History'}</button>
32
+ </div>
33
+ <div class="tab-contents">
34
+ <div class="tab-pane active" id="pane-editor">
35
+ <SegmentEditor labels={ui} />
36
+ </div>
37
+ <div class="tab-pane" id="pane-history">
38
+ <SpinHistory labels={ui} />
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <script src="./client.ts"></script>
@@ -0,0 +1,22 @@
1
+ ---
2
+ interface Props {
3
+ labels: Record<string, string>;
4
+ }
5
+
6
+ const { labels } = Astro.props;
7
+ ---
8
+
9
+ <div class="editor-card">
10
+ <span class="editor-label">{labels.presetLabel || 'Presets'}</span>
11
+ <div class="preset-group">
12
+ <button type="button" class="preset-btn" data-preset="yesno">{labels.presetYesNo || 'Yes / No'}</button>
13
+ <button type="button" class="preset-btn" data-preset="numbers">{labels.presetNumbers || 'Numbers 1-6'}</button>
14
+ <button type="button" class="preset-btn" data-preset="actions">{labels.presetActions || 'Actions'}</button>
15
+ <button type="button" class="preset-btn" data-preset="d20">{labels.presetD20 || 'D20'}</button>
16
+ <button type="button" class="preset-btn" data-preset="alignment">{labels.presetAlignment || 'Alignment'}</button>
17
+ <button type="button" class="preset-btn" data-preset="loot">{labels.presetLoot || 'Loot Rarity'}</button>
18
+ <button type="button" class="preset-btn" data-preset="custom">{labels.presetCustom || 'Custom'}</button>
19
+ </div>
20
+ <div class="segment-list" id="segment-list"></div>
21
+ <button type="button" id="add-segment-btn" class="btn-add-segment">{labels.addSegmentLabel || 'Add Segment'}</button>
22
+ </div>
@@ -0,0 +1,17 @@
1
+ ---
2
+ interface Props {
3
+ labels: Record<string, string>;
4
+ }
5
+
6
+ const { labels } = Astro.props;
7
+ ---
8
+
9
+ <div class="history-section">
10
+ <div class="history-header">
11
+ <h3 class="history-title">{labels.historyTitle || 'Spin History'}</h3>
12
+ <button type="button" id="clear-history-btn" class="btn-clear-history">{labels.clearHistory || 'Clear'}</button>
13
+ </div>
14
+ <div class="history-list" id="history-list">
15
+ <div class="history-empty" id="history-empty">{labels.noHistory || 'No spins yet.'}</div>
16
+ </div>
17
+ </div>
@@ -0,0 +1,13 @@
1
+ ---
2
+ interface Props {
3
+ labels: Record<string, string>;
4
+ }
5
+
6
+ const { labels } = Astro.props;
7
+ ---
8
+
9
+ <div class="result-section">
10
+ <div id="result-display" class="result-display">
11
+ <div class="result-placeholder">{labels.noHistory || 'Spin to see result'}</div>
12
+ </div>
13
+ </div>
@@ -0,0 +1,23 @@
1
+ ---
2
+ interface Props {
3
+ labels: Record<string, string>;
4
+ }
5
+
6
+ const { labels } = Astro.props;
7
+ const label = labels.title || 'Decision Wheel Spinner';
8
+ ---
9
+
10
+ <div class="wheel-canvas-container">
11
+ <canvas id="wheel-canvas" class="wheel-canvas" width="300" height="300" role="img" aria-label={label}></canvas>
12
+ </div>
13
+ <button type="button" id="mute-btn" class="mute-btn" aria-label="Mute Sounds">
14
+ <svg class="sound-on-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
15
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
16
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
17
+ </svg>
18
+ <svg class="sound-off-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;">
19
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
20
+ <line x1="23" y1="9" x2="17" y2="15"></line>
21
+ <line x1="17" y1="9" x2="23" y2="15"></line>
22
+ </svg>
23
+ </button>