web-a2e 1.0.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 (295) hide show
  1. package/.clangd +5 -0
  2. package/.mcp.json +12 -0
  3. package/CLAUDE.md +362 -0
  4. package/CMakeLists.txt +774 -0
  5. package/LICENSE +21 -0
  6. package/README.md +392 -0
  7. package/build-wasm/generated/roms.cpp +2447 -0
  8. package/docker-compose.staging.yml +9 -0
  9. package/docs/basic-rom-disassembly.md +6663 -0
  10. package/docs/softswitch-comparison.md +273 -0
  11. package/docs/thunderclock-debug.md +89 -0
  12. package/examples/cube.bas +72 -0
  13. package/examples/hello.s +55 -0
  14. package/examples/scroll.s +140 -0
  15. package/package.json +18 -0
  16. package/public/assets/apple-logo-old.png +0 -0
  17. package/public/assets/apple-logo.png +0 -0
  18. package/public/assets/drive-closed-light-on.png +0 -0
  19. package/public/assets/drive-closed.png +0 -0
  20. package/public/assets/drive-open-light-on.png +0 -0
  21. package/public/assets/drive-open.png +0 -0
  22. package/public/audio-worklet.js +82 -0
  23. package/public/disks/Apple DOS 3.3 January 1983.dsk +0 -0
  24. package/public/disks/ProDOS 2.4.3.po +0 -0
  25. package/public/disks/h32mb.2mg +0 -0
  26. package/public/disks/library.json +26 -0
  27. package/public/docs/llms/llm-assembler.txt +90 -0
  28. package/public/docs/llms/llm-basic-program.txt +256 -0
  29. package/public/docs/llms/llm-disk-drives.txt +72 -0
  30. package/public/docs/llms/llm-file-explorer.txt +50 -0
  31. package/public/docs/llms/llm-hard-drives.txt +80 -0
  32. package/public/docs/llms/llm-main.txt +51 -0
  33. package/public/docs/llms/llm-slot-configuration.txt +66 -0
  34. package/public/icons/icon-192.svg +4 -0
  35. package/public/icons/icon-512.svg +4 -0
  36. package/public/index.html +661 -0
  37. package/public/llms.txt +49 -0
  38. package/public/manifest.json +29 -0
  39. package/public/shaders/burnin.glsl +22 -0
  40. package/public/shaders/crt.glsl +706 -0
  41. package/public/shaders/edge.glsl +109 -0
  42. package/public/shaders/vertex.glsl +8 -0
  43. package/public/sw.js +186 -0
  44. package/roms/341-0027.bin +0 -0
  45. package/roms/341-0160-A-US-UK.bin +0 -0
  46. package/roms/341-0160-A.bin +0 -0
  47. package/roms/342-0273-A-US-UK.bin +0 -0
  48. package/roms/342-0349-B-C0-FF.bin +0 -0
  49. package/roms/Apple Mouse Interface Card ROM - 342-0270-C.bin +0 -0
  50. package/roms/Thunderclock Plus ROM.bin +0 -0
  51. package/scripts/generate_roms.sh +69 -0
  52. package/src/bindings/wasm_interface.cpp +1940 -0
  53. package/src/core/assembler/assembler.cpp +1239 -0
  54. package/src/core/assembler/assembler.hpp +115 -0
  55. package/src/core/audio/audio.cpp +160 -0
  56. package/src/core/audio/audio.hpp +81 -0
  57. package/src/core/basic/basic_detokenizer.cpp +436 -0
  58. package/src/core/basic/basic_detokenizer.hpp +41 -0
  59. package/src/core/basic/basic_tokenizer.cpp +286 -0
  60. package/src/core/basic/basic_tokenizer.hpp +26 -0
  61. package/src/core/basic/basic_tokens.hpp +295 -0
  62. package/src/core/cards/disk2_card.cpp +568 -0
  63. package/src/core/cards/disk2_card.hpp +316 -0
  64. package/src/core/cards/expansion_card.hpp +185 -0
  65. package/src/core/cards/mockingboard/ay8910.cpp +616 -0
  66. package/src/core/cards/mockingboard/ay8910.hpp +159 -0
  67. package/src/core/cards/mockingboard/via6522.cpp +530 -0
  68. package/src/core/cards/mockingboard/via6522.hpp +163 -0
  69. package/src/core/cards/mockingboard_card.cpp +312 -0
  70. package/src/core/cards/mockingboard_card.hpp +159 -0
  71. package/src/core/cards/mouse_card.cpp +654 -0
  72. package/src/core/cards/mouse_card.hpp +190 -0
  73. package/src/core/cards/smartport/block_device.cpp +202 -0
  74. package/src/core/cards/smartport/block_device.hpp +60 -0
  75. package/src/core/cards/smartport/smartport_card.cpp +603 -0
  76. package/src/core/cards/smartport/smartport_card.hpp +120 -0
  77. package/src/core/cards/thunderclock_card.cpp +237 -0
  78. package/src/core/cards/thunderclock_card.hpp +122 -0
  79. package/src/core/cpu/cpu6502.cpp +1609 -0
  80. package/src/core/cpu/cpu6502.hpp +203 -0
  81. package/src/core/debug/condition_evaluator.cpp +470 -0
  82. package/src/core/debug/condition_evaluator.hpp +87 -0
  83. package/src/core/disassembler/disassembler.cpp +552 -0
  84. package/src/core/disassembler/disassembler.hpp +171 -0
  85. package/src/core/disk-image/disk_image.hpp +267 -0
  86. package/src/core/disk-image/dsk_disk_image.cpp +827 -0
  87. package/src/core/disk-image/dsk_disk_image.hpp +204 -0
  88. package/src/core/disk-image/gcr_encoding.cpp +147 -0
  89. package/src/core/disk-image/gcr_encoding.hpp +78 -0
  90. package/src/core/disk-image/woz_disk_image.cpp +1049 -0
  91. package/src/core/disk-image/woz_disk_image.hpp +343 -0
  92. package/src/core/emulator.cpp +2126 -0
  93. package/src/core/emulator.hpp +434 -0
  94. package/src/core/filesystem/dos33.cpp +178 -0
  95. package/src/core/filesystem/dos33.hpp +66 -0
  96. package/src/core/filesystem/pascal.cpp +262 -0
  97. package/src/core/filesystem/pascal.hpp +87 -0
  98. package/src/core/filesystem/prodos.cpp +369 -0
  99. package/src/core/filesystem/prodos.hpp +119 -0
  100. package/src/core/input/keyboard.cpp +227 -0
  101. package/src/core/input/keyboard.hpp +111 -0
  102. package/src/core/mmu/mmu.cpp +1387 -0
  103. package/src/core/mmu/mmu.hpp +236 -0
  104. package/src/core/types.hpp +196 -0
  105. package/src/core/video/video.cpp +680 -0
  106. package/src/core/video/video.hpp +156 -0
  107. package/src/css/assembler-editor.css +1617 -0
  108. package/src/css/base.css +470 -0
  109. package/src/css/basic-debugger.css +791 -0
  110. package/src/css/basic-editor.css +792 -0
  111. package/src/css/controls.css +783 -0
  112. package/src/css/cpu-debugger.css +1413 -0
  113. package/src/css/debug-base.css +160 -0
  114. package/src/css/debug-windows.css +6455 -0
  115. package/src/css/disk-drives.css +406 -0
  116. package/src/css/documentation.css +392 -0
  117. package/src/css/file-explorer.css +867 -0
  118. package/src/css/hard-drive.css +180 -0
  119. package/src/css/layout.css +217 -0
  120. package/src/css/memory-windows.css +798 -0
  121. package/src/css/modals.css +510 -0
  122. package/src/css/monitor.css +425 -0
  123. package/src/css/release-notes.css +101 -0
  124. package/src/css/responsive.css +400 -0
  125. package/src/css/rule-builder.css +340 -0
  126. package/src/css/save-states.css +201 -0
  127. package/src/css/settings-windows.css +1231 -0
  128. package/src/css/window-switcher.css +150 -0
  129. package/src/js/agent/agent-manager.js +643 -0
  130. package/src/js/agent/agent-tools.js +293 -0
  131. package/src/js/agent/agent-version-tools.js +131 -0
  132. package/src/js/agent/assembler-tools.js +357 -0
  133. package/src/js/agent/basic-program-tools.js +894 -0
  134. package/src/js/agent/disk-tools.js +417 -0
  135. package/src/js/agent/file-explorer-tools.js +269 -0
  136. package/src/js/agent/index.js +13 -0
  137. package/src/js/agent/main-tools.js +222 -0
  138. package/src/js/agent/slot-tools.js +303 -0
  139. package/src/js/agent/smartport-tools.js +257 -0
  140. package/src/js/agent/window-tools.js +80 -0
  141. package/src/js/audio/audio-driver.js +417 -0
  142. package/src/js/audio/audio-worklet.js +85 -0
  143. package/src/js/audio/index.js +8 -0
  144. package/src/js/config/default-layout.js +34 -0
  145. package/src/js/config/version.js +8 -0
  146. package/src/js/data/apple2-rom-routines.js +577 -0
  147. package/src/js/debug/assembler-editor-window.js +2993 -0
  148. package/src/js/debug/basic-breakpoint-manager.js +529 -0
  149. package/src/js/debug/basic-program-parser.js +436 -0
  150. package/src/js/debug/basic-program-window.js +2594 -0
  151. package/src/js/debug/basic-variable-inspector.js +447 -0
  152. package/src/js/debug/breakpoint-manager.js +472 -0
  153. package/src/js/debug/cpu-debugger-window.js +2396 -0
  154. package/src/js/debug/index.js +22 -0
  155. package/src/js/debug/label-manager.js +238 -0
  156. package/src/js/debug/memory-browser-window.js +416 -0
  157. package/src/js/debug/memory-heat-map-window.js +481 -0
  158. package/src/js/debug/memory-map-window.js +206 -0
  159. package/src/js/debug/mockingboard-window.js +882 -0
  160. package/src/js/debug/mouse-card-window.js +355 -0
  161. package/src/js/debug/rule-builder-window.js +648 -0
  162. package/src/js/debug/soft-switch-window.js +458 -0
  163. package/src/js/debug/stack-viewer-window.js +221 -0
  164. package/src/js/debug/symbols.js +416 -0
  165. package/src/js/debug/trace-panel.js +291 -0
  166. package/src/js/debug/zero-page-watch-window.js +297 -0
  167. package/src/js/disk-manager/disk-drives-window.js +212 -0
  168. package/src/js/disk-manager/disk-operations.js +284 -0
  169. package/src/js/disk-manager/disk-persistence.js +301 -0
  170. package/src/js/disk-manager/disk-surface-renderer.js +388 -0
  171. package/src/js/disk-manager/drive-sounds.js +139 -0
  172. package/src/js/disk-manager/hard-drive-manager.js +481 -0
  173. package/src/js/disk-manager/hard-drive-persistence.js +187 -0
  174. package/src/js/disk-manager/hard-drive-window.js +57 -0
  175. package/src/js/disk-manager/index.js +890 -0
  176. package/src/js/display/display-settings-window.js +383 -0
  177. package/src/js/display/index.js +10 -0
  178. package/src/js/display/screen-window.js +342 -0
  179. package/src/js/display/webgl-renderer.js +705 -0
  180. package/src/js/file-explorer/disassembler.js +574 -0
  181. package/src/js/file-explorer/dos33.js +266 -0
  182. package/src/js/file-explorer/file-viewer.js +359 -0
  183. package/src/js/file-explorer/index.js +1261 -0
  184. package/src/js/file-explorer/prodos.js +549 -0
  185. package/src/js/file-explorer/utils.js +67 -0
  186. package/src/js/help/documentation-window.js +1096 -0
  187. package/src/js/help/index.js +10 -0
  188. package/src/js/help/release-notes-window.js +85 -0
  189. package/src/js/help/release-notes.js +612 -0
  190. package/src/js/input/gamepad-handler.js +176 -0
  191. package/src/js/input/index.js +12 -0
  192. package/src/js/input/input-handler.js +396 -0
  193. package/src/js/input/joystick-window.js +404 -0
  194. package/src/js/input/mouse-handler.js +99 -0
  195. package/src/js/input/text-selection.js +462 -0
  196. package/src/js/main.js +653 -0
  197. package/src/js/state/index.js +15 -0
  198. package/src/js/state/save-states-window.js +393 -0
  199. package/src/js/state/state-manager.js +409 -0
  200. package/src/js/state/state-persistence.js +218 -0
  201. package/src/js/ui/confirm.js +43 -0
  202. package/src/js/ui/disk-drive-positioner.js +347 -0
  203. package/src/js/ui/reminder-controller.js +129 -0
  204. package/src/js/ui/slot-configuration-window.js +560 -0
  205. package/src/js/ui/theme-manager.js +61 -0
  206. package/src/js/ui/toast.js +44 -0
  207. package/src/js/ui/ui-controller.js +897 -0
  208. package/src/js/ui/window-switcher.js +275 -0
  209. package/src/js/utils/basic-autocomplete.js +832 -0
  210. package/src/js/utils/basic-highlighting.js +473 -0
  211. package/src/js/utils/basic-tokenizer.js +153 -0
  212. package/src/js/utils/basic-tokens.js +117 -0
  213. package/src/js/utils/constants.js +28 -0
  214. package/src/js/utils/indexeddb-helper.js +225 -0
  215. package/src/js/utils/merlin-editor-support.js +905 -0
  216. package/src/js/utils/merlin-highlighting.js +551 -0
  217. package/src/js/utils/storage.js +125 -0
  218. package/src/js/utils/string-utils.js +19 -0
  219. package/src/js/utils/wasm-memory.js +54 -0
  220. package/src/js/windows/base-window.js +690 -0
  221. package/src/js/windows/index.js +9 -0
  222. package/src/js/windows/window-manager.js +375 -0
  223. package/tests/catch2/catch.hpp +17976 -0
  224. package/tests/common/basic_program_builder.cpp +119 -0
  225. package/tests/common/basic_program_builder.hpp +209 -0
  226. package/tests/common/disk_image_builder.cpp +444 -0
  227. package/tests/common/disk_image_builder.hpp +141 -0
  228. package/tests/common/test_helpers.hpp +118 -0
  229. package/tests/gcr/gcr-test.cpp +142 -0
  230. package/tests/integration/check-rom.js +70 -0
  231. package/tests/integration/compare-boot.js +239 -0
  232. package/tests/integration/crash-trace.js +102 -0
  233. package/tests/integration/disk-boot-test.js +264 -0
  234. package/tests/integration/memory-crash.js +108 -0
  235. package/tests/integration/nibble-read-test.js +249 -0
  236. package/tests/integration/phase-test.js +159 -0
  237. package/tests/integration/test_emulator.cpp +291 -0
  238. package/tests/integration/test_emulator_basic.cpp +91 -0
  239. package/tests/integration/test_emulator_debug.cpp +344 -0
  240. package/tests/integration/test_emulator_disk.cpp +153 -0
  241. package/tests/integration/test_emulator_state.cpp +163 -0
  242. package/tests/klaus/6502_functional_test.bin +0 -0
  243. package/tests/klaus/65C02_extended_opcodes_test.bin +0 -0
  244. package/tests/klaus/klaus_6502_test.cpp +184 -0
  245. package/tests/klaus/klaus_65c02_test.cpp +197 -0
  246. package/tests/thunderclock/thunderclock_mmu_test.cpp +304 -0
  247. package/tests/thunderclock/thunderclock_test.cpp +550 -0
  248. package/tests/unit/test_assembler.cpp +521 -0
  249. package/tests/unit/test_audio.cpp +196 -0
  250. package/tests/unit/test_ay8910.cpp +311 -0
  251. package/tests/unit/test_basic_detokenizer.cpp +265 -0
  252. package/tests/unit/test_basic_tokenizer.cpp +382 -0
  253. package/tests/unit/test_block_device.cpp +259 -0
  254. package/tests/unit/test_condition_evaluator.cpp +219 -0
  255. package/tests/unit/test_cpu6502.cpp +1301 -0
  256. package/tests/unit/test_cpu_addressing.cpp +361 -0
  257. package/tests/unit/test_cpu_cycle_counts.cpp +409 -0
  258. package/tests/unit/test_cpu_decimal.cpp +166 -0
  259. package/tests/unit/test_cpu_interrupts.cpp +285 -0
  260. package/tests/unit/test_disassembler.cpp +323 -0
  261. package/tests/unit/test_disk2_card.cpp +330 -0
  262. package/tests/unit/test_dos33.cpp +273 -0
  263. package/tests/unit/test_dsk_disk_image.cpp +315 -0
  264. package/tests/unit/test_expansion_card.cpp +178 -0
  265. package/tests/unit/test_gcr_encoding.cpp +232 -0
  266. package/tests/unit/test_keyboard.cpp +262 -0
  267. package/tests/unit/test_mmu.cpp +555 -0
  268. package/tests/unit/test_mmu_slots.cpp +323 -0
  269. package/tests/unit/test_mockingboard.cpp +352 -0
  270. package/tests/unit/test_mouse_card.cpp +386 -0
  271. package/tests/unit/test_pascal.cpp +248 -0
  272. package/tests/unit/test_prodos.cpp +259 -0
  273. package/tests/unit/test_smartport_card.cpp +321 -0
  274. package/tests/unit/test_thunderclock.cpp +354 -0
  275. package/tests/unit/test_via6522.cpp +323 -0
  276. package/tests/unit/test_video.cpp +319 -0
  277. package/tests/unit/test_woz_disk_image.cpp +257 -0
  278. package/vite.config.js +96 -0
  279. package/wiki/AI-Agent.md +372 -0
  280. package/wiki/Architecture-Overview.md +303 -0
  281. package/wiki/Audio-System.md +449 -0
  282. package/wiki/CPU-Emulation.md +477 -0
  283. package/wiki/Debugger.md +516 -0
  284. package/wiki/Disk-Drives.md +161 -0
  285. package/wiki/Disk-System-Internals.md +547 -0
  286. package/wiki/Display-Settings.md +88 -0
  287. package/wiki/Expansion-Slots.md +187 -0
  288. package/wiki/File-Explorer.md +259 -0
  289. package/wiki/Getting-Started.md +156 -0
  290. package/wiki/Home.md +69 -0
  291. package/wiki/Input-Devices.md +183 -0
  292. package/wiki/Keyboard-Shortcuts.md +158 -0
  293. package/wiki/Memory-System.md +364 -0
  294. package/wiki/Save-States.md +172 -0
  295. package/wiki/Video-Rendering.md +658 -0
@@ -0,0 +1,347 @@
1
+ /*
2
+ * disk-drive-positioner.js - Disk drive window positioning
3
+ *
4
+ * Written by
5
+ * Mike Daley <michael_daley@icloud.com>
6
+ */
7
+
8
+ /**
9
+ * DiskDrivePositioner - Handles drag-to-move for the disk drives container
10
+ * Supports dragging to reposition and double-click to reset to default position
11
+ */
12
+
13
+ export class DiskDrivePositioner {
14
+ constructor() {
15
+ // Custom position (user-defined via drag)
16
+ this.customPosition = null; // { x, y } or null for default
17
+
18
+ // Drag state
19
+ this.isDragging = false;
20
+ this.dragStart = null;
21
+
22
+ // Bind methods for event listeners
23
+ this.handleDragMove = this.handleDragMove.bind(this);
24
+ this.handleDragEnd = this.handleDragEnd.bind(this);
25
+ this.handleWindowResize = this.handleWindowResize.bind(this);
26
+ }
27
+
28
+ /**
29
+ * Initialize the positioner
30
+ */
31
+ init() {
32
+ this.loadSavedPosition();
33
+ this.constrainToViewport();
34
+ this.setupDrag();
35
+ this.applyPosition();
36
+ this.updatePositionIndicator();
37
+
38
+ // Listen for window resize to keep drives in view
39
+ window.addEventListener("resize", this.handleWindowResize);
40
+ }
41
+
42
+ /**
43
+ * Load saved position from localStorage
44
+ */
45
+ loadSavedPosition() {
46
+ const savedPosition = localStorage.getItem("a2e-drives-position");
47
+ if (savedPosition) {
48
+ try {
49
+ this.customPosition = JSON.parse(savedPosition);
50
+ } catch (e) {
51
+ this.customPosition = null;
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Ensure the saved position is within the current viewport bounds.
58
+ * If the window is outside the visible area, bring it back on screen.
59
+ */
60
+ constrainToViewport() {
61
+ if (!this.customPosition) return;
62
+
63
+ const container = document.querySelector(".disk-drives-container");
64
+ if (!container) return;
65
+
66
+ // Temporarily apply position to measure the element
67
+ container.classList.add("free-position");
68
+ container.style.left = this.customPosition.x + "px";
69
+ container.style.top = this.customPosition.y + "px";
70
+
71
+ const rect = container.getBoundingClientRect();
72
+ const header = document.querySelector("header");
73
+ const footer = document.querySelector("footer");
74
+
75
+ const headerHeight = header ? header.offsetHeight : 0;
76
+ const footerHeight = footer ? footer.offsetHeight : 0;
77
+
78
+ // Check if the element is significantly off-screen
79
+ // Allow some tolerance (at least 50px must be visible)
80
+ const minVisible = 50;
81
+ let needsAdjustment = false;
82
+ let newX = this.customPosition.x;
83
+ let newY = this.customPosition.y;
84
+
85
+ // Check horizontal bounds
86
+ if (rect.right < minVisible) {
87
+ // Too far left - bring right edge into view
88
+ newX = minVisible - rect.width;
89
+ needsAdjustment = true;
90
+ } else if (rect.left > window.innerWidth - minVisible) {
91
+ // Too far right - bring left edge into view
92
+ newX = window.innerWidth - minVisible;
93
+ needsAdjustment = true;
94
+ }
95
+
96
+ // Check vertical bounds
97
+ if (rect.bottom < headerHeight + minVisible) {
98
+ // Too far up - bring bottom edge into view
99
+ newY = headerHeight + minVisible - rect.height;
100
+ needsAdjustment = true;
101
+ } else if (rect.top > window.innerHeight - footerHeight - minVisible) {
102
+ // Too far down - bring top edge into view
103
+ newY = window.innerHeight - footerHeight - minVisible;
104
+ needsAdjustment = true;
105
+ }
106
+
107
+ // Fully constrain to keep entire element on screen
108
+ newX = Math.max(0, Math.min(window.innerWidth - rect.width, newX));
109
+ newY = Math.max(headerHeight, Math.min(window.innerHeight - footerHeight - rect.height, newY));
110
+
111
+ if (needsAdjustment || newX !== this.customPosition.x || newY !== this.customPosition.y) {
112
+ this.customPosition = { x: newX, y: newY };
113
+ localStorage.setItem("a2e-drives-position", JSON.stringify(this.customPosition));
114
+ }
115
+
116
+ // Reset styles - applyPosition will set them properly
117
+ container.classList.remove("free-position");
118
+ container.style.left = "";
119
+ container.style.top = "";
120
+ }
121
+
122
+ /**
123
+ * Set up drag-to-move functionality
124
+ */
125
+ setupDrag() {
126
+ const container = document.querySelector(".disk-drives-container");
127
+ if (!container) return;
128
+
129
+ // Add position indicator
130
+ this.createPositionIndicator(container);
131
+
132
+ // Drag on the container
133
+ container.addEventListener("mousedown", (e) => {
134
+ // Don't start drag if clicking on buttons, inputs, or dropdowns
135
+ if (
136
+ e.target.closest("button") ||
137
+ e.target.closest("input") ||
138
+ e.target.closest(".recent-dropdown") ||
139
+ e.target.closest(".drives-position-indicator")
140
+ ) {
141
+ return;
142
+ }
143
+
144
+ e.preventDefault();
145
+ this.startDrag(e);
146
+ });
147
+
148
+ // Double-click to reset position
149
+ container.addEventListener("dblclick", (e) => {
150
+ // Don't reset if clicking on controls
151
+ if (
152
+ e.target.closest("button") ||
153
+ e.target.closest("input") ||
154
+ e.target.closest(".recent-dropdown") ||
155
+ e.target.closest(".drives-position-indicator")
156
+ ) {
157
+ return;
158
+ }
159
+
160
+ this.resetToDefault();
161
+ });
162
+
163
+ document.addEventListener("mousemove", this.handleDragMove);
164
+ document.addEventListener("mouseup", this.handleDragEnd);
165
+ }
166
+
167
+ /**
168
+ * Create the position indicator element
169
+ */
170
+ createPositionIndicator(container) {
171
+ const indicator = document.createElement("button");
172
+ indicator.id = "drives-position-indicator";
173
+ indicator.className = "drives-position-indicator hidden";
174
+ indicator.title = "Custom position - Click to reset (or double-click drives)";
175
+ indicator.innerHTML = `
176
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
177
+ <circle cx="12" cy="12" r="3"></circle>
178
+ <path d="M12 2v4"></path>
179
+ <path d="M12 18v4"></path>
180
+ <path d="M2 12h4"></path>
181
+ <path d="M18 12h4"></path>
182
+ </svg>
183
+ `;
184
+ indicator.addEventListener("click", (e) => {
185
+ e.stopPropagation();
186
+ this.resetToDefault();
187
+ });
188
+ container.appendChild(indicator);
189
+ }
190
+
191
+ /**
192
+ * Start dragging
193
+ */
194
+ startDrag(e) {
195
+ // Don't allow dragging in full-page mode or fullscreen
196
+ if (document.body.classList.contains("full-page-mode") || document.fullscreenElement) {
197
+ return;
198
+ }
199
+
200
+ const container = document.querySelector(".disk-drives-container");
201
+ if (!container) return;
202
+
203
+ this.isDragging = true;
204
+ container.classList.add("dragging");
205
+
206
+ const rect = container.getBoundingClientRect();
207
+ this.dragStart = {
208
+ mouseX: e.clientX,
209
+ mouseY: e.clientY,
210
+ elemX: rect.left,
211
+ elemY: rect.top,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Handle mouse move during drag
217
+ */
218
+ handleDragMove(e) {
219
+ if (!this.isDragging) return;
220
+
221
+ const dx = e.clientX - this.dragStart.mouseX;
222
+ const dy = e.clientY - this.dragStart.mouseY;
223
+
224
+ // Calculate new position
225
+ let newX = this.dragStart.elemX + dx;
226
+ let newY = this.dragStart.elemY + dy;
227
+
228
+ // Get bounds
229
+ const container = document.querySelector(".disk-drives-container");
230
+ if (!container) return;
231
+
232
+ const rect = container.getBoundingClientRect();
233
+ const header = document.querySelector("header");
234
+ const footer = document.querySelector("footer");
235
+
236
+ const headerHeight = header ? header.offsetHeight : 0;
237
+ const footerHeight = footer ? footer.offsetHeight : 0;
238
+
239
+ // Constrain to viewport (keep entire element on screen)
240
+ newX = Math.max(0, Math.min(window.innerWidth - rect.width, newX));
241
+ newY = Math.max(headerHeight, Math.min(window.innerHeight - footerHeight - rect.height, newY));
242
+
243
+ this.customPosition = { x: newX, y: newY };
244
+ this.applyPosition();
245
+ }
246
+
247
+ /**
248
+ * Handle mouse up to complete drag
249
+ */
250
+ handleDragEnd() {
251
+ if (!this.isDragging) return;
252
+
253
+ const container = document.querySelector(".disk-drives-container");
254
+ if (container) {
255
+ container.classList.remove("dragging");
256
+ }
257
+
258
+ this.isDragging = false;
259
+ this.dragStart = null;
260
+
261
+ if (this.customPosition) {
262
+ localStorage.setItem("a2e-drives-position", JSON.stringify(this.customPosition));
263
+ this.updatePositionIndicator();
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Apply the current position
269
+ */
270
+ applyPosition() {
271
+ const container = document.querySelector(".disk-drives-container");
272
+ if (!container) return;
273
+
274
+ if (this.customPosition) {
275
+ container.classList.add("free-position");
276
+ container.style.left = this.customPosition.x + "px";
277
+ container.style.top = this.customPosition.y + "px";
278
+ } else {
279
+ container.classList.remove("free-position");
280
+ container.style.left = "";
281
+ container.style.top = "";
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Reset to default position
287
+ */
288
+ resetToDefault() {
289
+ this.customPosition = null;
290
+ localStorage.removeItem("a2e-drives-position");
291
+ this.applyPosition();
292
+ this.updatePositionIndicator();
293
+ }
294
+
295
+ /**
296
+ * Update the position indicator visibility
297
+ */
298
+ updatePositionIndicator() {
299
+ const indicator = document.getElementById("drives-position-indicator");
300
+ if (!indicator) return;
301
+
302
+ if (this.customPosition) {
303
+ indicator.classList.remove("hidden");
304
+ } else {
305
+ indicator.classList.add("hidden");
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Handle window resize - keep drives within viewport
311
+ */
312
+ handleWindowResize() {
313
+ if (!this.customPosition) return;
314
+
315
+ const container = document.querySelector(".disk-drives-container");
316
+ if (!container) return;
317
+
318
+ const rect = container.getBoundingClientRect();
319
+ const header = document.querySelector("header");
320
+ const footer = document.querySelector("footer");
321
+
322
+ const headerHeight = header ? header.offsetHeight : 0;
323
+ const footerHeight = footer ? footer.offsetHeight : 0;
324
+
325
+ let newX = this.customPosition.x;
326
+ let newY = this.customPosition.y;
327
+
328
+ // Constrain to new viewport size
329
+ newX = Math.max(0, Math.min(window.innerWidth - rect.width, newX));
330
+ newY = Math.max(headerHeight, Math.min(window.innerHeight - footerHeight - rect.height, newY));
331
+
332
+ if (newX !== this.customPosition.x || newY !== this.customPosition.y) {
333
+ this.customPosition = { x: newX, y: newY };
334
+ this.applyPosition();
335
+ localStorage.setItem("a2e-drives-position", JSON.stringify(this.customPosition));
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Clean up resources
341
+ */
342
+ destroy() {
343
+ document.removeEventListener("mousemove", this.handleDragMove);
344
+ document.removeEventListener("mouseup", this.handleDragEnd);
345
+ window.removeEventListener("resize", this.handleWindowResize);
346
+ }
347
+ }
@@ -0,0 +1,129 @@
1
+ /*
2
+ * reminder-controller.js - UI reminder notifications
3
+ *
4
+ * Written by
5
+ * Mike Daley <michael_daley@icloud.com>
6
+ */
7
+
8
+ /**
9
+ * ReminderController - Manages floating reminder tooltips
10
+ * Handles power, resize, and drives toggle reminders with positioning and persistence
11
+ */
12
+
13
+ export class ReminderController {
14
+ constructor() {
15
+ this.isPowerReminderVisible = false;
16
+ this.isBasicReminderVisible = false;
17
+
18
+ window.addEventListener("resize", () => this.repositionAll());
19
+ }
20
+
21
+ /**
22
+ * Position a reminder tooltip below a target element with an arrow pointing to it.
23
+ * Centers the reminder on the element, clamps to viewport, and sets arrow position.
24
+ * @param {string} reminderId - The reminder element's ID
25
+ * @param {string|Element} target - The target element ID or element to position below
26
+ * @param {number} defaultWidth - Fallback width if reminder not yet rendered
27
+ */
28
+ positionReminderBelowElement(reminderId, target, defaultWidth = 200) {
29
+ const reminder = document.getElementById(reminderId);
30
+ const targetEl =
31
+ typeof target === "string" ? document.getElementById(target) : target;
32
+ if (!reminder || !targetEl) return;
33
+
34
+ const targetRect = targetEl.getBoundingClientRect();
35
+ const targetCenterX = targetRect.left + targetRect.width / 2;
36
+
37
+ const reminderRect = reminder.getBoundingClientRect();
38
+ const reminderWidth = reminderRect.width || defaultWidth;
39
+
40
+ // Position reminder centered below target, clamped to viewport
41
+ let reminderLeft = targetCenterX - reminderWidth / 2;
42
+ const padding = 16;
43
+ const maxLeft = window.innerWidth - reminderWidth - padding;
44
+ reminderLeft = Math.max(padding, Math.min(reminderLeft, maxLeft));
45
+
46
+ // Calculate arrow position relative to reminder
47
+ const arrowLeft = targetCenterX - reminderLeft;
48
+
49
+ reminder.style.left = `${reminderLeft}px`;
50
+ reminder.style.top = `${targetRect.bottom + 15}px`;
51
+ reminder.style.setProperty("--arrow-left", `${arrowLeft}px`);
52
+ }
53
+
54
+ // Power reminder methods
55
+
56
+ repositionPowerReminder() {
57
+ this.positionReminderBelowElement("power-reminder", "btn-power", 200);
58
+ }
59
+
60
+ showPowerReminder(show) {
61
+ const reminder = document.getElementById("power-reminder");
62
+ if (!reminder) return;
63
+
64
+ // Check if already dismissed (first visit)
65
+ if (show && localStorage.getItem("a2e-power-reminder-dismissed")) {
66
+ return;
67
+ }
68
+
69
+ if (show) {
70
+ this.isPowerReminderVisible = true;
71
+ reminder.classList.remove("hidden");
72
+ requestAnimationFrame(() => {
73
+ this.repositionPowerReminder();
74
+ });
75
+ } else {
76
+ this.isPowerReminderVisible = false;
77
+ reminder.classList.add("hidden");
78
+ }
79
+ }
80
+
81
+ dismissPowerReminder() {
82
+ this.showPowerReminder(false);
83
+ localStorage.setItem("a2e-power-reminder-dismissed", "true");
84
+ }
85
+
86
+ // BASIC reminder methods (shows when powered on without a disk)
87
+
88
+ showBasicReminder(show) {
89
+ const reminder = document.getElementById("basic-reminder");
90
+ if (!reminder) return;
91
+
92
+ // Check if already dismissed
93
+ if (show && localStorage.getItem("a2e-basic-reminder-dismissed")) {
94
+ return;
95
+ }
96
+
97
+ if (show) {
98
+ this.isBasicReminderVisible = true;
99
+ reminder.classList.remove("hidden");
100
+ requestAnimationFrame(() => {
101
+ this.repositionBasicReminder();
102
+ });
103
+ } else {
104
+ this.isBasicReminderVisible = false;
105
+ reminder.classList.add("hidden");
106
+ }
107
+ }
108
+
109
+ repositionBasicReminder() {
110
+ this.positionReminderBelowElement("basic-reminder", "btn-warm-reset", 220);
111
+ }
112
+
113
+ dismissBasicReminder() {
114
+ this.showBasicReminder(false);
115
+ localStorage.setItem("a2e-basic-reminder-dismissed", "true");
116
+ }
117
+
118
+ /**
119
+ * Reposition all visible reminders (call after resize)
120
+ */
121
+ repositionAll() {
122
+ if (this.isPowerReminderVisible) {
123
+ requestAnimationFrame(() => this.repositionPowerReminder());
124
+ }
125
+ if (this.isBasicReminderVisible) {
126
+ requestAnimationFrame(() => this.repositionBasicReminder());
127
+ }
128
+ }
129
+ }