@jjlmoya/utils-tabletop 1.4.0 → 1.6.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 (163) hide show
  1. package/package.json +5 -3
  2. package/src/entries.ts +15 -1
  3. package/src/index.ts +3 -0
  4. package/src/tests/locale_completeness.test.ts +2 -1
  5. package/src/tests/tool_validation.test.ts +2 -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/investigation-board/bibliography.astro +16 -0
  65. package/src/tool/investigation-board/bibliography.ts +12 -0
  66. package/src/tool/investigation-board/boardContextMenu.ts +64 -0
  67. package/src/tool/investigation-board/client.ts +223 -0
  68. package/src/tool/investigation-board/component.astro +23 -0
  69. package/src/tool/investigation-board/components/BoardCanvas.astro +39 -0
  70. package/src/tool/investigation-board/components/CardDrawer.astro +73 -0
  71. package/src/tool/investigation-board/components/ConnectionModal.astro +49 -0
  72. package/src/tool/investigation-board/components/ContextMenu.astro +26 -0
  73. package/src/tool/investigation-board/components/ControlPanel.astro +57 -0
  74. package/src/tool/investigation-board/connectionModal.ts +60 -0
  75. package/src/tool/investigation-board/dom.ts +89 -0
  76. package/src/tool/investigation-board/drawer.ts +110 -0
  77. package/src/tool/investigation-board/entry.ts +59 -0
  78. package/src/tool/investigation-board/i18n/de.ts +126 -0
  79. package/src/tool/investigation-board/i18n/en.ts +127 -0
  80. package/src/tool/investigation-board/i18n/es.ts +126 -0
  81. package/src/tool/investigation-board/i18n/fr.ts +126 -0
  82. package/src/tool/investigation-board/i18n/id.ts +126 -0
  83. package/src/tool/investigation-board/i18n/it.ts +126 -0
  84. package/src/tool/investigation-board/i18n/ja.ts +126 -0
  85. package/src/tool/investigation-board/i18n/ko.ts +126 -0
  86. package/src/tool/investigation-board/i18n/nl.ts +126 -0
  87. package/src/tool/investigation-board/i18n/pl.ts +126 -0
  88. package/src/tool/investigation-board/i18n/pt.ts +126 -0
  89. package/src/tool/investigation-board/i18n/ru.ts +126 -0
  90. package/src/tool/investigation-board/i18n/sv.ts +126 -0
  91. package/src/tool/investigation-board/i18n/tr.ts +126 -0
  92. package/src/tool/investigation-board/i18n/zh.ts +126 -0
  93. package/src/tool/investigation-board/index.ts +11 -0
  94. package/src/tool/investigation-board/interactDrag.ts +58 -0
  95. package/src/tool/investigation-board/investigation-board.css +799 -0
  96. package/src/tool/investigation-board/layout.ts +16 -0
  97. package/src/tool/investigation-board/logic.test.ts +105 -0
  98. package/src/tool/investigation-board/logic.ts +106 -0
  99. package/src/tool/investigation-board/panzoom.ts +24 -0
  100. package/src/tool/investigation-board/renderer.ts +256 -0
  101. package/src/tool/investigation-board/seo.astro +16 -0
  102. package/src/tool/investigation-board/stateManager.ts +126 -0
  103. package/src/tool/investigation-board/types.ts +26 -0
  104. package/src/tool/rpg-initiative-tracker/bibliography.astro +16 -0
  105. package/src/tool/rpg-initiative-tracker/bibliography.ts +12 -0
  106. package/src/tool/rpg-initiative-tracker/client.ts +201 -0
  107. package/src/tool/rpg-initiative-tracker/component.astro +23 -0
  108. package/src/tool/rpg-initiative-tracker/components/AddForm.astro +49 -0
  109. package/src/tool/rpg-initiative-tracker/components/CombatantList.astro +16 -0
  110. package/src/tool/rpg-initiative-tracker/components/TurnControls.astro +28 -0
  111. package/src/tool/rpg-initiative-tracker/entry.ts +50 -0
  112. package/src/tool/rpg-initiative-tracker/i18n/de.ts +266 -0
  113. package/src/tool/rpg-initiative-tracker/i18n/en.ts +263 -0
  114. package/src/tool/rpg-initiative-tracker/i18n/es.ts +264 -0
  115. package/src/tool/rpg-initiative-tracker/i18n/fr.ts +263 -0
  116. package/src/tool/rpg-initiative-tracker/i18n/id.ts +263 -0
  117. package/src/tool/rpg-initiative-tracker/i18n/it.ts +263 -0
  118. package/src/tool/rpg-initiative-tracker/i18n/ja.ts +263 -0
  119. package/src/tool/rpg-initiative-tracker/i18n/ko.ts +263 -0
  120. package/src/tool/rpg-initiative-tracker/i18n/nl.ts +263 -0
  121. package/src/tool/rpg-initiative-tracker/i18n/pl.ts +264 -0
  122. package/src/tool/rpg-initiative-tracker/i18n/pt.ts +263 -0
  123. package/src/tool/rpg-initiative-tracker/i18n/ru.ts +264 -0
  124. package/src/tool/rpg-initiative-tracker/i18n/sv.ts +263 -0
  125. package/src/tool/rpg-initiative-tracker/i18n/tr.ts +263 -0
  126. package/src/tool/rpg-initiative-tracker/i18n/zh.ts +263 -0
  127. package/src/tool/rpg-initiative-tracker/index.ts +9 -0
  128. package/src/tool/rpg-initiative-tracker/logic.test.ts +121 -0
  129. package/src/tool/rpg-initiative-tracker/logic.ts +122 -0
  130. package/src/tool/rpg-initiative-tracker/rpg-initiative-tracker.css +617 -0
  131. package/src/tool/rpg-initiative-tracker/seo.astro +16 -0
  132. package/src/tool/rpg-initiative-tracker/types.ts +15 -0
  133. package/src/tool/score-tracker/bibliography.astro +16 -0
  134. package/src/tool/score-tracker/bibliography.ts +12 -0
  135. package/src/tool/score-tracker/client.ts +248 -0
  136. package/src/tool/score-tracker/component.astro +34 -0
  137. package/src/tool/score-tracker/components/Leaderboard.astro +19 -0
  138. package/src/tool/score-tracker/components/PlayerSetup.astro +32 -0
  139. package/src/tool/score-tracker/components/RoundScoring.astro +22 -0
  140. package/src/tool/score-tracker/components/ScoreHistory.astro +25 -0
  141. package/src/tool/score-tracker/entry.ts +57 -0
  142. package/src/tool/score-tracker/i18n/de.ts +278 -0
  143. package/src/tool/score-tracker/i18n/en.ts +278 -0
  144. package/src/tool/score-tracker/i18n/es.ts +251 -0
  145. package/src/tool/score-tracker/i18n/fr.ts +278 -0
  146. package/src/tool/score-tracker/i18n/id.ts +278 -0
  147. package/src/tool/score-tracker/i18n/it.ts +278 -0
  148. package/src/tool/score-tracker/i18n/ja.ts +278 -0
  149. package/src/tool/score-tracker/i18n/ko.ts +278 -0
  150. package/src/tool/score-tracker/i18n/nl.ts +278 -0
  151. package/src/tool/score-tracker/i18n/pl.ts +278 -0
  152. package/src/tool/score-tracker/i18n/pt.ts +278 -0
  153. package/src/tool/score-tracker/i18n/ru.ts +278 -0
  154. package/src/tool/score-tracker/i18n/sv.ts +278 -0
  155. package/src/tool/score-tracker/i18n/tr.ts +278 -0
  156. package/src/tool/score-tracker/i18n/zh.ts +278 -0
  157. package/src/tool/score-tracker/index.ts +9 -0
  158. package/src/tool/score-tracker/logic.test.ts +83 -0
  159. package/src/tool/score-tracker/logic.ts +122 -0
  160. package/src/tool/score-tracker/score-tracker.css +1029 -0
  161. package/src/tool/score-tracker/seo.astro +16 -0
  162. package/src/tool/score-tracker/types.ts +17 -0
  163. package/src/tools.ts +11 -0
@@ -0,0 +1,145 @@
1
+ import type { WheelSegment, SpinResultEntry, PresetKey } from './types';
2
+
3
+ export const SEGMENT_COLORS = [
4
+ '#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
5
+ '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
6
+ '#6366f1', '#84cc16', '#06b6d4', '#e11d48',
7
+ ];
8
+
9
+ const PRESETS: Record<PresetKey, WheelSegment[]> = {
10
+ yesno: [
11
+ { id: 'seg-0', label: 'Yes', color: SEGMENT_COLORS[2], weight: 1 },
12
+ { id: 'seg-1', label: 'No', color: SEGMENT_COLORS[1], weight: 1 },
13
+ ],
14
+ numbers: Array.from({ length: 6 }, (_, i) => ({
15
+ id: `seg-${i}`,
16
+ label: String(i + 1),
17
+ color: SEGMENT_COLORS[i],
18
+ weight: 1,
19
+ })),
20
+ actions: [
21
+ { id: 'seg-0', label: 'Attack', color: SEGMENT_COLORS[1], weight: 1 },
22
+ { id: 'seg-1', label: 'Defend', color: SEGMENT_COLORS[0], weight: 1 },
23
+ { id: 'seg-2', label: 'Explore', color: SEGMENT_COLORS[2], weight: 1 },
24
+ { id: 'seg-3', label: 'Negotiate', color: SEGMENT_COLORS[3], weight: 1 },
25
+ { id: 'seg-4', label: 'Rest', color: SEGMENT_COLORS[4], weight: 1 },
26
+ ],
27
+ d20: Array.from({ length: 20 }, (_, i) => ({
28
+ id: `seg-${i}`,
29
+ label: String(i + 1),
30
+ color: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
31
+ weight: 1,
32
+ })),
33
+ alignment: [
34
+ { id: 'seg-0', label: 'Lawful Good', color: SEGMENT_COLORS[2], weight: 1 },
35
+ { id: 'seg-1', label: 'Neutral Good', color: SEGMENT_COLORS[0], weight: 1 },
36
+ { id: 'seg-2', label: 'Chaotic Good', color: SEGMENT_COLORS[6], weight: 1 },
37
+ { id: 'seg-3', label: 'Lawful Neutral', color: SEGMENT_COLORS[3], weight: 1 },
38
+ { id: 'seg-4', label: 'True Neutral', color: SEGMENT_COLORS[4], weight: 1 },
39
+ { id: 'seg-5', label: 'Chaotic Neutral', color: SEGMENT_COLORS[7], weight: 1 },
40
+ { id: 'seg-6', label: 'Lawful Evil', color: SEGMENT_COLORS[1], weight: 1 },
41
+ { id: 'seg-7', label: 'Neutral Evil', color: SEGMENT_COLORS[11], weight: 1 },
42
+ { id: 'seg-8', label: 'Chaotic Evil', color: SEGMENT_COLORS[5], weight: 1 },
43
+ ],
44
+ loot: [
45
+ { id: 'seg-0', label: 'Common', color: '#9ca3af', weight: 5 },
46
+ { id: 'seg-1', label: 'Uncommon', color: '#22c55e', weight: 4 },
47
+ { id: 'seg-2', label: 'Rare', color: '#3b82f6', weight: 3 },
48
+ { id: 'seg-3', label: 'Very Rare', color: '#a855f7', weight: 2 },
49
+ { id: 'seg-4', label: 'Legendary', color: '#eab308', weight: 1 },
50
+ ],
51
+ custom: [
52
+ { id: 'seg-0', label: 'Option A', color: SEGMENT_COLORS[0], weight: 1 },
53
+ { id: 'seg-1', label: 'Option B', color: SEGMENT_COLORS[1], weight: 1 },
54
+ { id: 'seg-2', label: 'Option C', color: SEGMENT_COLORS[2], weight: 1 },
55
+ ],
56
+ };
57
+
58
+ export function getPresetSegments(preset: PresetKey): WheelSegment[] {
59
+ return (PRESETS[preset] || PRESETS.custom).map((s) => ({ ...s }));
60
+ }
61
+
62
+ export function addSegment(segments: WheelSegment[]): WheelSegment[] {
63
+ const idx = segments.length;
64
+ return [
65
+ ...segments,
66
+ {
67
+ id: `seg-${Date.now()}`,
68
+ label: `Option ${String.fromCharCode(65 + Math.min(idx, 25))}`,
69
+ color: SEGMENT_COLORS[idx % SEGMENT_COLORS.length],
70
+ weight: 1,
71
+ },
72
+ ];
73
+ }
74
+
75
+ export function removeSegment(segments: WheelSegment[], id: string): WheelSegment[] {
76
+ if (segments.length <= 1) return segments;
77
+ return segments.filter((s) => s.id !== id);
78
+ }
79
+
80
+ export function updateSegment(
81
+ segments: WheelSegment[],
82
+ id: string,
83
+ field: 'label' | 'color' | 'weight',
84
+ value: string,
85
+ ): WheelSegment[] {
86
+ return segments.map((s) => {
87
+ if (s.id === id) {
88
+ if (field === 'weight') {
89
+ return { ...s, weight: Math.max(1, parseInt(value, 10) || 1) };
90
+ }
91
+ return { ...s, [field]: value };
92
+ }
93
+ return s;
94
+ });
95
+ }
96
+
97
+ export function selectRandomSegment(segments: WheelSegment[]): number {
98
+ const totalWeight = segments.reduce((sum, s) => sum + (s.weight || 1), 0);
99
+ const r = Math.random() * totalWeight;
100
+ let acc = 0;
101
+ for (let i = 0; i < segments.length; i++) {
102
+ acc += segments[i].weight || 1;
103
+ if (r < acc) {
104
+ return i;
105
+ }
106
+ }
107
+ return segments.length - 1;
108
+ }
109
+
110
+ export function getSelectedIndex(
111
+ segments: WheelSegment[],
112
+ currentAngle: number,
113
+ ): number {
114
+ const totalWeight = segments.reduce((sum, s) => sum + (s.weight || 1), 0);
115
+ const segmentAngle = (360 - (currentAngle % 360)) % 360;
116
+ let acc = 0;
117
+ for (let i = 0; i < segments.length; i++) {
118
+ const nextAcc = acc + (segments[i].weight || 1);
119
+ const startAngle = (acc / totalWeight) * 360;
120
+ const endAngle = (nextAcc / totalWeight) * 360;
121
+ if (segmentAngle >= startAngle && segmentAngle < endAngle) {
122
+ return i;
123
+ }
124
+ acc = nextAcc;
125
+ }
126
+ return 0;
127
+ }
128
+
129
+ export function getArcAngle(segmentCount: number): number {
130
+ return 360 / segmentCount;
131
+ }
132
+
133
+ export function addHistoryItem(
134
+ history: SpinResultEntry[],
135
+ segment: WheelSegment,
136
+ index: number,
137
+ ): SpinResultEntry[] {
138
+ const entry: SpinResultEntry = {
139
+ segment,
140
+ index,
141
+ timestamp: Date.now(),
142
+ };
143
+ const updated = [entry, ...history];
144
+ return updated.slice(0, 10);
145
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { SEORenderer } 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;
11
+ const loader = decisionWheel.i18n[locale] || decisionWheel.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,14 @@
1
+ export interface WheelSegment {
2
+ id: string;
3
+ label: string;
4
+ color: string;
5
+ weight?: number;
6
+ }
7
+
8
+ export interface SpinResultEntry {
9
+ segment: WheelSegment;
10
+ index: number;
11
+ timestamp: number;
12
+ }
13
+
14
+ export type PresetKey = 'yesno' | 'numbers' | 'actions' | 'custom' | 'd20' | 'alignment' | 'loot';
@@ -0,0 +1,121 @@
1
+ import type { WheelSegment, SpinResultEntry } from './types';
2
+
3
+ function selected(value: number | undefined, target: number): string {
4
+ return value === target ? 'selected' : '';
5
+ }
6
+
7
+ function createSegmentRow(seg: WheelSegment, t: (key: string) => string): HTMLDivElement {
8
+ const row = document.createElement('div');
9
+ row.className = 'segment-row';
10
+ const labelPlaceholder = t('segmentLabelPlaceholder') || 'Label';
11
+ const weightLabel = t('weightLabel') || 'Weight';
12
+ const removeLabel = t('removeSegmentLabel') || 'Remove';
13
+ const sw = seg.weight;
14
+ row.innerHTML = `
15
+ <input type="color" class="segment-color" data-id="${seg.id}" value="${seg.color}" />
16
+ <input type="text" class="segment-input" data-id="${seg.id}" value="${seg.label}" placeholder="${labelPlaceholder}" />
17
+ <select class="segment-weight" data-id="${seg.id}" title="${weightLabel}">
18
+ <option value="1" ${selected(sw, 1)}>w1</option>
19
+ <option value="2" ${selected(sw, 2)}>w2</option>
20
+ <option value="3" ${selected(sw, 3)}>w3</option>
21
+ <option value="4" ${selected(sw, 4)}>w4</option>
22
+ <option value="5" ${selected(sw, 5)}>w5</option>
23
+ </select>
24
+ <button type="button" class="btn-remove-segment" data-id="${seg.id}">${removeLabel}</button>
25
+ `;
26
+ return row;
27
+ }
28
+
29
+ export function bindSegmentEvents(
30
+ segmentList: HTMLElement,
31
+ onUpdateSegment: (id: string, field: string, value: string) => void,
32
+ onRemoveSegment: (id: string) => void,
33
+ ): void {
34
+ segmentList.querySelectorAll('.segment-color').forEach((el) => {
35
+ el.addEventListener('input', (e) => {
36
+ const input = e.target as HTMLInputElement;
37
+ onUpdateSegment(input.dataset.id || '', 'color', input.value);
38
+ });
39
+ });
40
+ segmentList.querySelectorAll('.segment-input').forEach((el) => {
41
+ el.addEventListener('input', (e) => {
42
+ const input = e.target as HTMLInputElement;
43
+ onUpdateSegment(input.dataset.id || '', 'label', input.value);
44
+ });
45
+ });
46
+ segmentList.querySelectorAll('.segment-weight').forEach((el) => {
47
+ el.addEventListener('change', (e) => {
48
+ const select = e.target as HTMLSelectElement;
49
+ onUpdateSegment(select.dataset.id || '', 'weight', select.value);
50
+ });
51
+ });
52
+ segmentList.querySelectorAll('.btn-remove-segment').forEach((el) => {
53
+ el.addEventListener('click', (e) => {
54
+ const btn = e.currentTarget as HTMLButtonElement;
55
+ onRemoveSegment(btn.dataset.id || '');
56
+ });
57
+ });
58
+ }
59
+
60
+ export function renderSegmentEditor(
61
+ segmentList: HTMLElement,
62
+ segments: WheelSegment[],
63
+ t: (key: string) => string,
64
+ onBind: () => void,
65
+ ): void {
66
+ segmentList.innerHTML = '';
67
+ if (segments.length === 0) {
68
+ segmentList.innerHTML = `<div class="no-segments-msg">${t('noSegments') || 'Add segments to the wheel before spinning.'}</div>`;
69
+ return;
70
+ }
71
+ segments.forEach((seg) => {
72
+ segmentList.appendChild(createSegmentRow(seg, t));
73
+ });
74
+ onBind();
75
+ }
76
+
77
+ export function renderResult(
78
+ resultDisplay: HTMLElement,
79
+ result: SpinResultEntry | null,
80
+ spinning: boolean,
81
+ t: (key: string) => string,
82
+ ): void {
83
+ if (!result) {
84
+ resultDisplay.innerHTML = `<div class="result-placeholder">${spinning ? 'Spinning...' : (t('noHistory') || 'Spin to see result')}</div>`;
85
+ return;
86
+ }
87
+ resultDisplay.innerHTML = `<div class="result-card" id="result-card">
88
+ <div class="result-header">${t('resultHeading') || 'Result'}</div>
89
+ <span class="result-value"><span class="result-color-dot" style="background:${result.segment.color}"></span>${result.segment.label}</span>
90
+ </div>`;
91
+ }
92
+
93
+ export function triggerEmphasis(): void {
94
+ const card = document.getElementById('result-card');
95
+ if (!card) return;
96
+ card.classList.remove('result-emphasis');
97
+ void card.offsetWidth;
98
+ card.classList.add('result-emphasis');
99
+ }
100
+
101
+ export function renderHistory(
102
+ historyList: HTMLElement,
103
+ historyEmpty: HTMLElement,
104
+ history: SpinResultEntry[],
105
+ ): void {
106
+ if (history.length === 0) {
107
+ historyEmpty.style.display = 'block';
108
+ historyList.querySelectorAll('.history-item').forEach((el) => el.remove());
109
+ return;
110
+ }
111
+ historyEmpty.style.display = 'none';
112
+ historyList.querySelectorAll('.history-item').forEach((el) => el.remove());
113
+ history.forEach((h) => {
114
+ const item = document.createElement('div');
115
+ item.className = 'history-item';
116
+ const time = new Date(h.timestamp);
117
+ const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
118
+ item.innerHTML = `<span class="history-item-label"><span class="history-item-dot" style="background:${h.segment.color}"></span>${h.segment.label}</span><span class="history-item-time">${timeStr}</span>`;
119
+ historyList.appendChild(item);
120
+ });
121
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { fantasyRunesTranslator } 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 = fantasyRunesTranslator.i18n[locale] || fantasyRunesTranslator.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Elder Futhark (Wikipedia)',
6
+ url: 'https://en.wikipedia.org/wiki/Elder_Futhark',
7
+ },
8
+ {
9
+ name: 'Anglo-Saxon Runes (Wikipedia)',
10
+ url: 'https://en.wikipedia.org/wiki/Anglo-Saxon_runes',
11
+ },
12
+ {
13
+ name: 'Runic Unicode Block',
14
+ url: 'https://en.wikipedia.org/wiki/Runic_(Unicode_block)',
15
+ },
16
+ ];
@@ -0,0 +1,205 @@
1
+ import type { RuneAlphabetKey, RuneCharacterResult, RuneCharacter } from './types';
2
+ import { translateText } from './logic';
3
+
4
+ const wrap = document.querySelector('.runes-wrap') as HTMLElement;
5
+ const input = document.getElementById('runes-input') as HTMLInputElement;
6
+ const seedIn = document.getElementById('runes-seed') as HTMLInputElement;
7
+ const seedTag = document.getElementById('seed-tag') as HTMLElement;
8
+ const seedShuf = document.getElementById('seed-shuffle-btn') as HTMLElement;
9
+ const seedToggle = document.getElementById('seed-toggle-btn') as HTMLElement;
10
+ const seedDrawer = document.getElementById('seed-drawer') as HTMLElement;
11
+ const output = document.getElementById('runes-output') as HTMLElement;
12
+ const detailCard = document.getElementById('detail-card') as HTMLElement;
13
+ const dName = document.getElementById('detail-name') as HTMLElement;
14
+ const dMeaning = document.getElementById('detail-meaning') as HTMLElement;
15
+ const dPhonetic = document.getElementById('detail-phonetic') as HTMLElement;
16
+ const copyBtn = document.getElementById('runes-copy-btn') as HTMLElement;
17
+ const clearBtn = document.getElementById('runes-clear-btn') as HTMLElement;
18
+ const pills = document.getElementById('alphabet-pills') as HTMLElement;
19
+ const gmBtn = document.getElementById('runes-gm-btn') as HTMLElement;
20
+ const gmOverlay = document.getElementById('gm-overlay') as HTMLElement;
21
+ const gmDisplay = document.getElementById('gm-runes') as HTMLElement;
22
+ const gmParts = document.getElementById('gm-particles') as HTMLElement;
23
+ const gmExit = document.getElementById('gm-exit-btn') as HTMLElement;
24
+ const refFoundBtn = document.getElementById('runes-ref-found-btn') as HTMLElement;
25
+ const refAllBtn = document.getElementById('runes-ref-all-btn') as HTMLElement;
26
+ const refOverlay = document.getElementById('ref-overlay') as HTMLElement;
27
+ const refGrid = document.getElementById('ref-grid') as HTMLElement;
28
+ const refTitle = document.getElementById('ref-title') as HTMLElement;
29
+ const refClose = document.getElementById('ref-close-btn') as HTMLElement;
30
+ const ALPHA_KEY = 'runes-alphabet';
31
+ let alphabet: RuneAlphabetKey = (localStorage.getItem(ALPHA_KEY) as RuneAlphabetKey) || 'elder-futhark';
32
+ let result: RuneCharacterResult[] = [];
33
+ let seed: number | null = null;
34
+ const discovered = new Set<string>();
35
+
36
+ function ui(key: string): string {
37
+ try { return JSON.parse(wrap.getAttribute('data-ui') || '{}')[key] || ''; } catch { return ''; }
38
+ }
39
+
40
+ function spawnPart(sym: string, x: number, y: number) {
41
+ const el = document.createElement('div');
42
+ el.className = 'particle'; el.textContent = sym;
43
+ el.style.cssText = `left:${x}px;top:${y}px;position:fixed`;
44
+ document.body.appendChild(el);
45
+ setTimeout(() => el.remove(), 1000);
46
+ }
47
+
48
+ function spawnGmParts() {
49
+ gmParts.innerHTML = '';
50
+ const rs = ['ᚠ','ᛟ','ᚱ','ᚲ','ᚨ','ᚢ','ᛉ','ᚷ','ᚹ','ᛞ','ᛁ','ᛃ','ᛚ','ᛗ','ᚾ','ᛟ','ᛈ','ᛏ','ᛒ','ᛇ','ᛉ','ᚱ','ᛋ','ᚠ','ᚢ','ᚦ','ᚬ'];
51
+ for (let i = 0; i < 10; i++) {
52
+ const p = document.createElement('span');
53
+ p.className = 'gm-float'; p.textContent = rs[Math.floor(Math.random() * rs.length)];
54
+ p.style.left = Math.random() * 100 + '%';
55
+ p.style.fontSize = (0.7 + Math.random() * 1.3) + 'rem';
56
+ p.style.animationDuration = (3 + Math.random() * 3) + 's';
57
+ p.style.animationDelay = Math.random() * 4 + 's';
58
+ gmParts.appendChild(p);
59
+ }
60
+ }
61
+
62
+ function selPill(key: RuneAlphabetKey) {
63
+ pills.querySelectorAll('.pill').forEach(b => b.classList.toggle('active', b.getAttribute('data-alphabet') === key));
64
+ }
65
+
66
+ function emptyOut() {
67
+ output.innerHTML = '<div class="runes-output-empty"><span class="empty-rune">ᛟ</span><span class="empty-label">' + ui('noInput') + '</span></div>';
68
+ output.classList.remove('has-chars');
69
+ }
70
+
71
+ function showDetail(c: RuneCharacter | null) {
72
+ if (!c) { detailCard.classList.remove('vis'); return; }
73
+ dName.textContent = c.name; dMeaning.textContent = c.meaning; dPhonetic.textContent = c.phonetic;
74
+ detailCard.classList.add('vis');
75
+ }
76
+
77
+ function clickRune(cr: RuneCharacterResult, card: HTMLElement) {
78
+ output.querySelectorAll('.rune-card').forEach(c => c.classList.remove('active'));
79
+ card.classList.add('active');
80
+ if (cr.char) {
81
+ showDetail(cr.char);
82
+ const r = card.getBoundingClientRect();
83
+ spawnPart(cr.char.symbol, r.left + r.width / 2, r.top);
84
+ }
85
+ }
86
+
87
+ function render(rs: RuneCharacterResult[]) {
88
+ output.classList.add('has-chars');
89
+ result = rs;
90
+ const shim = document.createElement('div');
91
+ shim.className = 'shimmer';
92
+ output.innerHTML = '';
93
+ output.appendChild(shim);
94
+ setTimeout(() => {
95
+ const g = document.createElement('div');
96
+ g.className = 'runes-grid';
97
+ rs.forEach((cr, i) => {
98
+ const c = document.createElement('div');
99
+ c.className = 'rune-card'; c.style.animationDelay = i * 25 + 'ms';
100
+ const sy = document.createElement('span');
101
+ sy.className = 'sym'; sy.textContent = cr.char ? cr.char.symbol : '?';
102
+ const le = document.createElement('span');
103
+ le.className = 'let'; le.textContent = cr.input.toUpperCase();
104
+ c.appendChild(sy); c.appendChild(le);
105
+ c.addEventListener('click', () => clickRune(cr, c));
106
+ g.appendChild(c);
107
+ });
108
+ output.innerHTML = '';
109
+ output.appendChild(g);
110
+ }, 300);
111
+ }
112
+
113
+ function translate() {
114
+ const t = input.value.trim();
115
+ if (!t) { emptyOut(); detailCard.classList.remove('vis'); result = []; return; }
116
+ for (const ch of t.toLowerCase()) { if (ch >= 'a' && ch <= 'z') discovered.add(ch); }
117
+ render(translateText(t, alphabet, seed).characters);
118
+ detailCard.classList.remove('vis');
119
+ }
120
+
121
+ function copyText() {
122
+ if (result.length === 0) return;
123
+ const t = result.map(c => (c.char ? c.char.symbol : c.input)).join('');
124
+ navigator.clipboard.writeText(t).catch(() => {});
125
+ }
126
+
127
+ function clearAll() {
128
+ input.value = ''; seedIn.value = ''; seed = null; seedTag.style.display = 'none';
129
+ emptyOut(); detailCard.classList.remove('vis'); result = [];
130
+ }
131
+
132
+ function applySeed() {
133
+ const v = parseInt(seedIn.value);
134
+ seed = isNaN(v) || v < 1 ? null : v;
135
+ seedTag.style.display = seed ? '' : 'none';
136
+ if (input.value.trim()) translate();
137
+ }
138
+
139
+ function randSeed() {
140
+ seedIn.value = String(Math.floor(Math.random() * 99999) + 1);
141
+ applySeed();
142
+ }
143
+
144
+ function openGm() {
145
+ if (result.length === 0) return;
146
+ gmDisplay.textContent = result.map(c => (c.char ? c.char.symbol : c.input)).join('');
147
+ gmOverlay.classList.add('active');
148
+ spawnGmParts();
149
+ }
150
+
151
+ function closeGm() { gmOverlay.classList.remove('active'); }
152
+
153
+ function openRef(mode: 'found' | 'all') {
154
+ const mapping = translateText('abcdefghijklmnopqrstuvwxyz', alphabet, seed).characters;
155
+ refTitle.textContent = mode === 'found' ? 'Discovered Runes' : 'Full Rune Dictionary';
156
+ refGrid.innerHTML = mapping.map(c => {
157
+ if (mode === 'found' && !discovered.has(c.input)) return '';
158
+ return `<div class="ref-cell lit"><span class="ref-sym">${c.char ? c.char.symbol : '?'}</span><span class="ref-let">${c.input}</span></div>`;
159
+ }).join('');
160
+ refOverlay.classList.add('active');
161
+ spawnRefParticles();
162
+ }
163
+
164
+ function spawnRefParticles() {
165
+ const container = refOverlay;
166
+ const existing = container.querySelectorAll('.gm-float');
167
+ existing.forEach(e => e.remove());
168
+ const rs = ['ᚠ','ᛟ','ᚱ','ᚲ','ᚨ','ᚢ','ᛉ','ᚷ','ᚹ','ᛞ','ᛁ','ᛃ','ᛚ','ᛗ','ᚾ','ᛟ','ᛈ','ᛏ','ᛒ','ᛇ','ᛉ','ᚱ','ᛋ','ᚠ','ᚢ','ᚦ','ᚬ'];
169
+ for (let i = 0; i < 8; i++) {
170
+ const p = document.createElement('span');
171
+ p.className = 'gm-float'; p.textContent = rs[Math.floor(Math.random() * rs.length)];
172
+ p.style.left = Math.random() * 100 + '%';
173
+ p.style.fontSize = (0.7 + Math.random() * 1.3) + 'rem';
174
+ p.style.animationDuration = (4 + Math.random() * 3) + 's';
175
+ p.style.animationDelay = Math.random() * 4 + 's';
176
+ container.appendChild(p);
177
+ }
178
+ }
179
+ function closeRef() { refOverlay.classList.remove('active'); }
180
+
181
+ input.addEventListener('input', () => { if (input.value.trim()) translate(); });
182
+ seedIn.addEventListener('input', applySeed);
183
+ seedShuf.addEventListener('click', randSeed);
184
+ seedToggle.addEventListener('click', () => seedDrawer.classList.toggle('open'));
185
+ copyBtn.addEventListener('click', copyText);
186
+ clearBtn.addEventListener('click', clearAll);
187
+ gmBtn.addEventListener('click', openGm);
188
+ gmExit.addEventListener('click', closeGm);
189
+ refFoundBtn.addEventListener('click', () => openRef('found'));
190
+ refAllBtn.addEventListener('click', () => openRef('all'));
191
+ refClose.addEventListener('click', closeRef);
192
+ refOverlay.addEventListener('click', (e) => { if (e.target === refOverlay) closeRef(); });
193
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeGm(); closeRef(); } });
194
+
195
+ pills.addEventListener('click', (e) => {
196
+ const b = (e.target as HTMLElement).closest('.pill') as HTMLButtonElement;
197
+ if (!b) return;
198
+ alphabet = b.getAttribute('data-alphabet') as RuneAlphabetKey;
199
+ localStorage.setItem(ALPHA_KEY, alphabet);
200
+ selPill(alphabet);
201
+ if (input.value.trim()) translate();
202
+ });
203
+
204
+ selPill(alphabet);
205
+ emptyOut();
@@ -0,0 +1,95 @@
1
+ ---
2
+ import './fantasy-runes-translator.css';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="runes-wrap" data-ui={JSON.stringify(ui)}>
12
+ <div class="runes-top-bar">
13
+ <div class="alphabet-pills" id="alphabet-pills">
14
+ <button type="button" class="pill" data-alphabet="elder-futhark"><span class="pill-rune">&#x16A8;</span><span class="pill-label">{ui.elderFuthark}</span></button>
15
+ <button type="button" class="pill" data-alphabet="anglo-saxon"><span class="pill-rune">&#x16AA;</span><span class="pill-label">{ui.angloSaxon}</span></button>
16
+ <button type="button" class="pill" data-alphabet="fantasy"><span class="pill-rune">&#x16DF;</span><span class="pill-label">{ui.fantasy}</span></button>
17
+ <button type="button" class="pill" data-alphabet="theban"><span class="pill-rune">&#x16A0;</span><span class="pill-label">{ui.theban}</span></button>
18
+ <button type="button" class="pill" data-alphabet="enochian"><span class="pill-rune">&#x16C1;</span><span class="pill-label">{ui.enochian}</span></button>
19
+ <button type="button" class="pill" data-alphabet="daemonic"><span class="pill-rune">&#x16CA;</span><span class="pill-label">{ui.daemonic}</span></button>
20
+ <button type="button" class="pill" data-alphabet="ogham"><span class="pill-rune">&#x16D7;</span><span class="pill-label">{ui.ogham}</span></button>
21
+ </div>
22
+ <button type="button" class="seed-cog" id="seed-toggle-btn" title="Seed">&#x2699;</button>
23
+ </div>
24
+
25
+ <div class="seed-drawer" id="seed-drawer">
26
+ <div class="seed-inner">
27
+ <div class="seed-field">
28
+ <input type="number" id="runes-seed" placeholder={ui.seedPlaceholder} min="1" max="999999" />
29
+ <button type="button" class="seed-shuffle" id="seed-shuffle-btn">&#x21BB;</button>
30
+ </div>
31
+ <span class="seed-tag" id="seed-tag">{ui.seedApplied}</span>
32
+ <span class="seed-hint">Shuffles which rune matches each letter</span>
33
+ </div>
34
+ </div>
35
+
36
+ <div class="runes-input-area">
37
+ <input type="text" id="runes-input" placeholder={ui.inputPlaceholder} autocomplete="off" />
38
+ </div>
39
+
40
+ <div class="runes-output" id="runes-output">
41
+ <div class="runes-output-empty">
42
+ <span class="empty-rune">&#x16DF;</span>
43
+ <span class="empty-label">{ui.noInput}</span>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="detail-card" id="detail-card">
48
+ <div class="detail-row">
49
+ <span class="detail-lbl">NAME</span>
50
+ <span class="detail-val" id="detail-name"></span>
51
+ </div>
52
+ <div class="detail-row">
53
+ <span class="detail-lbl">MEANING</span>
54
+ <span class="detail-val" id="detail-meaning"></span>
55
+ </div>
56
+ <div class="detail-row">
57
+ <span class="detail-lbl">SOUND</span>
58
+ <span class="detail-val" id="detail-phonetic"></span>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="runes-bottom-bar">
63
+ <button type="button" class="bot-btn gm-reveal" id="runes-gm-btn">
64
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
65
+ {ui.gmMode}
66
+ </button>
67
+ <div class="bot-spacer"></div>
68
+ <button type="button" class="bot-btn" id="runes-copy-btn" title={ui.copy}>
69
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
70
+ </button>
71
+ <button type="button" class="bot-btn" id="runes-clear-btn" title={ui.clear}>
72
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
73
+ </button>
74
+ <button type="button" class="bot-btn" id="runes-ref-all-btn" title="Full Alphabet">
75
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
76
+ </button>
77
+ <button type="button" class="bot-btn" id="runes-ref-found-btn" title="Discovered">
78
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="0.5" fill="currentColor"/></svg>
79
+ </button>
80
+ </div>
81
+ </div>
82
+
83
+ <div class="ref-overlay" id="ref-overlay">
84
+ <button type="button" class="ref-close" id="ref-close-btn">&#x2715;</button>
85
+ <div class="ref-title" id="ref-title">Rune Dictionary</div>
86
+ <div class="ref-grid" id="ref-grid"></div>
87
+ </div>
88
+
89
+ <div class="gm-overlay" id="gm-overlay">
90
+ <div class="gm-particles" id="gm-particles"></div>
91
+ <button type="button" class="gm-exit" id="gm-exit-btn">{ui.exitGmMode}</button>
92
+ <div class="gm-runes" id="gm-runes"></div>
93
+ </div>
94
+
95
+ <script src="./client.ts"></script>