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,890 @@
1
+ /*
2
+ * index.js - Disk manager subsystem initialization and exports
3
+ *
4
+ * Written by
5
+ * Mike Daley <michael_daley@icloud.com>
6
+ */
7
+
8
+ import { DriveSounds } from "./drive-sounds.js";
9
+ import { showToast } from "../ui/toast.js";
10
+ import { DiskSurfaceRenderer } from "./disk-surface-renderer.js";
11
+ import {
12
+ loadDisk,
13
+ loadDiskFromData,
14
+ insertBlankDisk,
15
+ ejectDisk,
16
+ performEject,
17
+ saveDiskWithPicker,
18
+ } from "./disk-operations.js";
19
+ import {
20
+ loadDiskFromStorage,
21
+ saveDiskToStorage,
22
+ getRecentDisks,
23
+ loadRecentDisk,
24
+ addToRecentDisks,
25
+ clearRecentDisks,
26
+ } from "./disk-persistence.js";
27
+ import { createDatabaseManager } from "../utils/indexeddb-helper.js";
28
+
29
+ const CACHE_STORE = "images";
30
+
31
+ const libraryCacheDb = createDatabaseManager({
32
+ dbName: "a2e-disk-library-cache",
33
+ version: 1,
34
+ onUpgrade: (event) => {
35
+ const database = event.target.result;
36
+ if (!database.objectStoreNames.contains(CACHE_STORE)) {
37
+ database.createObjectStore(CACHE_STORE, { keyPath: "id" });
38
+ }
39
+ },
40
+ });
41
+
42
+ /**
43
+ * Create a new drive state object with default values
44
+ * @returns {Object} Drive state object
45
+ */
46
+ function createDriveState() {
47
+ return {
48
+ input: null,
49
+ insertBtn: null,
50
+ blankBtn: null,
51
+ ejectBtn: null,
52
+ browseBtn: null,
53
+ recentBtn: null,
54
+ recentDropdown: null,
55
+ nameLabel: null,
56
+ trackLabel: null,
57
+ filename: null,
58
+ lastTrack: -1,
59
+ surfaceRenderer: null,
60
+ trackAccessCounts: null,
61
+ lastHeadPosition: -1,
62
+ maxAccessCount: 0,
63
+ lastDecayTime: 0,
64
+ };
65
+ }
66
+
67
+ export class DiskManager {
68
+ constructor(wasmModule) {
69
+ this.wasmModule = wasmModule;
70
+ this.drives = [createDriveState(), createDriveState()];
71
+
72
+ // Save modal state
73
+ this.pendingEjectDrive = null;
74
+ this.saveModal = null;
75
+ this.saveFilenameInput = null;
76
+
77
+ // Canvas for focus management
78
+ this.canvas = null;
79
+
80
+ // Active recent disks dropdown
81
+ this.activeDropdown = null;
82
+
83
+ // Drive sounds
84
+ this.sounds = new DriveSounds();
85
+
86
+ // Callback for when a disk is loaded
87
+ this.onDiskLoaded = null;
88
+
89
+ // File explorer reference (set by main.js)
90
+ this.fileExplorer = null;
91
+
92
+ // Set by main.js so surface rendering can be skipped when hidden
93
+ this.drivesWindowVisible = true;
94
+ }
95
+
96
+ init() {
97
+ // Get canvas for focus management
98
+ this.canvas = document.getElementById("screen");
99
+
100
+ // Set up drive 1
101
+ this.setupDrive(0, "disk1");
102
+
103
+ // Set up drive 2
104
+ this.setupDrive(1, "disk2");
105
+
106
+ // Set up drag and drop on the display
107
+ this.setupDragDrop();
108
+
109
+ // Set up save modal
110
+ this.setupSaveModal();
111
+
112
+ // Close dropdown when clicking outside
113
+ document.addEventListener("click", (e) => {
114
+ if (this.activeDropdown && !e.target.closest(".recent-container")) {
115
+ this.closeRecentDropdown();
116
+ }
117
+ });
118
+
119
+ // Restore any persisted disks from previous session
120
+ this.restoreDisks();
121
+ }
122
+
123
+ /**
124
+ * Restore disks from IndexedDB that were inserted in a previous session
125
+ */
126
+ async restoreDisks() {
127
+ for (let driveNum = 0; driveNum < 2; driveNum++) {
128
+ try {
129
+ const diskData = await loadDiskFromStorage(driveNum);
130
+ if (diskData) {
131
+ const drive = this.drives[driveNum];
132
+ loadDiskFromData({
133
+ wasmModule: this.wasmModule,
134
+ drive,
135
+ driveNum,
136
+ filename: diskData.filename,
137
+ data: diskData.data,
138
+ onSuccess: (filename) => {
139
+ this.setDiskName(driveNum, filename);
140
+ if (this.onDiskLoaded) this.onDiskLoaded(driveNum, filename);
141
+ },
142
+ onError: (error) =>
143
+ console.error(
144
+ `Failed to restore disk in drive ${driveNum + 1}:`,
145
+ error,
146
+ ),
147
+ });
148
+ }
149
+ } catch (error) {
150
+ console.error(`Error restoring disk for drive ${driveNum + 1}:`, error);
151
+ }
152
+ }
153
+ }
154
+
155
+ refocusCanvas() {
156
+ if (this.canvas) {
157
+ setTimeout(() => this.canvas.focus(), 0);
158
+ }
159
+ }
160
+
161
+ setupDrive(driveNum, elementId) {
162
+ const container = document.getElementById(elementId);
163
+ if (!container) return;
164
+
165
+ const drive = this.drives[driveNum];
166
+ drive.input = container.querySelector(`#${elementId}-input`);
167
+ drive.insertBtn = container.querySelector(".disk-insert");
168
+ drive.blankBtn = container.querySelector(".disk-blank");
169
+ drive.ejectBtn = container.querySelector(".disk-eject");
170
+ drive.browseBtn = container.querySelector(".disk-browse");
171
+ drive.recentBtn = container.querySelector(".disk-recent");
172
+ drive.recentDropdown = container.querySelector(".recent-dropdown");
173
+ drive.nameLabel = container.querySelector(".disk-name");
174
+ drive.trackLabel = container.querySelector(".disk-track");
175
+ const surfaceCanvas = container.querySelector(".disk-surface");
176
+ if (surfaceCanvas) {
177
+ drive.surfaceRenderer = new DiskSurfaceRenderer(surfaceCanvas);
178
+ drive.trackAccessCounts = new Uint32Array(35);
179
+ }
180
+
181
+ // Insert button click
182
+ if (drive.insertBtn) {
183
+ drive.insertBtn.addEventListener("click", () => {
184
+ drive.input.click();
185
+ });
186
+ }
187
+
188
+ // Blank disk button click
189
+ if (drive.blankBtn) {
190
+ drive.blankBtn.addEventListener("click", () => {
191
+ this.insertBlankDisk(driveNum);
192
+ this.refocusCanvas();
193
+ });
194
+ }
195
+
196
+ // Recent button click
197
+ if (drive.recentBtn) {
198
+ drive.recentBtn.addEventListener("click", (e) => {
199
+ e.stopPropagation();
200
+ this.toggleRecentDropdown(driveNum);
201
+ });
202
+ }
203
+
204
+ // File input change
205
+ if (drive.input) {
206
+ drive.input.addEventListener("change", (e) => {
207
+ if (e.target.files.length > 0) {
208
+ this.loadDisk(driveNum, e.target.files[0]);
209
+ }
210
+ this.refocusCanvas();
211
+ });
212
+ }
213
+
214
+ // Eject button click
215
+ if (drive.ejectBtn) {
216
+ drive.ejectBtn.addEventListener("click", () => {
217
+ this.ejectDisk(driveNum);
218
+ this.refocusCanvas();
219
+ });
220
+ }
221
+
222
+ // Browse button click
223
+ if (drive.browseBtn) {
224
+ drive.browseBtn.addEventListener("click", () => {
225
+ if (this.fileExplorer) {
226
+ this.fileExplorer.showFloppyDisk(driveNum);
227
+ }
228
+ this.refocusCanvas();
229
+ });
230
+ }
231
+ }
232
+
233
+ setupDragDrop() {
234
+ const displayContainer = document.getElementById("monitor-frame");
235
+ if (!displayContainer) return;
236
+
237
+ displayContainer.addEventListener("dragover", (e) => {
238
+ e.preventDefault();
239
+ e.stopPropagation();
240
+ displayContainer.classList.add("drag-over");
241
+ });
242
+
243
+ displayContainer.addEventListener("dragleave", (e) => {
244
+ e.preventDefault();
245
+ e.stopPropagation();
246
+ displayContainer.classList.remove("drag-over");
247
+ });
248
+
249
+ displayContainer.addEventListener("drop", (e) => {
250
+ e.preventDefault();
251
+ e.stopPropagation();
252
+ displayContainer.classList.remove("drag-over");
253
+
254
+ if (e.dataTransfer.files.length > 0) {
255
+ // Load into first empty drive, or drive 1 if both full
256
+ const driveNum = !this.drives[0].filename
257
+ ? 0
258
+ : !this.drives[1].filename
259
+ ? 1
260
+ : 0;
261
+ this.loadDisk(driveNum, e.dataTransfer.files[0]);
262
+ }
263
+ });
264
+ }
265
+
266
+ setupSaveModal() {
267
+ this.saveModal = document.getElementById("save-disk-modal");
268
+ this.saveFilenameInput = document.getElementById("save-disk-filename");
269
+ const confirmBtn = document.getElementById("save-disk-confirm");
270
+ const cancelBtn = document.getElementById("save-disk-cancel");
271
+
272
+ if (confirmBtn) {
273
+ confirmBtn.addEventListener("click", () => {
274
+ this.handleSaveConfirm();
275
+ });
276
+ }
277
+
278
+ if (cancelBtn) {
279
+ cancelBtn.addEventListener("click", () => {
280
+ this.handleSaveCancel();
281
+ });
282
+ }
283
+
284
+ // Handle click on backdrop (::backdrop) to close
285
+ if (this.saveModal) {
286
+ this.saveModal.addEventListener("click", (e) => {
287
+ // Close if clicking on the dialog itself (backdrop area)
288
+ if (e.target === this.saveModal) {
289
+ this.handleSaveCancel();
290
+ }
291
+ });
292
+
293
+ // Handle native cancel event (Escape key)
294
+ this.saveModal.addEventListener("cancel", (e) => {
295
+ e.preventDefault(); // Prevent default close to handle our own logic
296
+ this.handleSaveCancel();
297
+ });
298
+ }
299
+
300
+ // Handle Enter key in filename input
301
+ if (this.saveFilenameInput) {
302
+ this.saveFilenameInput.addEventListener("keydown", (e) => {
303
+ if (e.key === "Enter") {
304
+ this.handleSaveConfirm();
305
+ }
306
+ // Escape is now handled by native dialog cancel event
307
+ });
308
+ }
309
+ }
310
+
311
+ showSaveModal(driveNum) {
312
+ const drive = this.drives[driveNum];
313
+ this.pendingEjectDrive = driveNum;
314
+
315
+ // Set default filename based on current disk name
316
+ let defaultName = drive.filename || `disk${driveNum + 1}.dsk`;
317
+ // Ensure it has an extension
318
+ if (!defaultName.includes(".")) {
319
+ defaultName += ".dsk";
320
+ }
321
+
322
+ if (this.saveFilenameInput) {
323
+ this.saveFilenameInput.value = defaultName;
324
+ }
325
+
326
+ if (this.saveModal) {
327
+ this.saveModal.showModal();
328
+ // Focus the input and select the filename (without extension)
329
+ if (this.saveFilenameInput) {
330
+ this.saveFilenameInput.focus();
331
+ const dotIndex = defaultName.lastIndexOf(".");
332
+ if (dotIndex > 0) {
333
+ this.saveFilenameInput.setSelectionRange(0, dotIndex);
334
+ } else {
335
+ this.saveFilenameInput.select();
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ hideSaveModal() {
342
+ if (this.saveModal && this.saveModal.open) {
343
+ this.saveModal.close();
344
+ }
345
+ this.pendingEjectDrive = null;
346
+ }
347
+
348
+ async handleSaveConfirm() {
349
+ if (this.pendingEjectDrive === null) return;
350
+
351
+ const driveNum = this.pendingEjectDrive;
352
+ const defaultFilename =
353
+ this.saveFilenameInput?.value || `disk${driveNum + 1}.dsk`;
354
+
355
+ this.hideSaveModal();
356
+
357
+ // Try to save with file picker
358
+ const saved = await saveDiskWithPicker(
359
+ this.wasmModule,
360
+ driveNum,
361
+ defaultFilename,
362
+ );
363
+
364
+ // Always eject after save attempt (user may have cancelled picker)
365
+ this.performEject(driveNum);
366
+
367
+ if (saved) {
368
+ console.log(`Disk saved successfully`);
369
+ }
370
+ }
371
+
372
+ handleSaveCancel() {
373
+ if (this.pendingEjectDrive === null) return;
374
+
375
+ const driveNum = this.pendingEjectDrive;
376
+ this.hideSaveModal();
377
+ this.performEject(driveNum);
378
+ }
379
+
380
+ /**
381
+ * Set the disk name label with scrolling animation if the name is too long
382
+ */
383
+ setDiskName(driveNum, name) {
384
+ const drive = this.drives[driveNum];
385
+ if (!drive.nameLabel) return;
386
+
387
+ // Create inner span for the text if it doesn't exist
388
+ let textSpan = drive.nameLabel.querySelector(".disk-name-text");
389
+ if (!textSpan) {
390
+ textSpan = document.createElement("span");
391
+ textSpan.className = "disk-name-text";
392
+ drive.nameLabel.innerHTML = "";
393
+ drive.nameLabel.appendChild(textSpan);
394
+ }
395
+
396
+ // Set the text
397
+ textSpan.textContent = name;
398
+
399
+ // Remove scrolling class initially
400
+ drive.nameLabel.classList.remove("scrolling");
401
+
402
+ // Check if text overflows after a brief delay to allow rendering
403
+ requestAnimationFrame(() => {
404
+ const containerWidth = drive.nameLabel.offsetWidth;
405
+ const textWidth = textSpan.offsetWidth;
406
+
407
+ if (textWidth > containerWidth) {
408
+ // Calculate scroll distance (negative to scroll left)
409
+ const scrollDistance = containerWidth - textWidth;
410
+
411
+ // Calculate duration based on text length (roughly 40px per second)
412
+ const duration = Math.max(3, Math.abs(scrollDistance) / 40);
413
+
414
+ // Set CSS custom properties for the animation
415
+ drive.nameLabel.style.setProperty(
416
+ "--scroll-distance",
417
+ `${scrollDistance}px`,
418
+ );
419
+ drive.nameLabel.style.setProperty("--scroll-duration", `${duration}s`);
420
+
421
+ // Enable scrolling
422
+ drive.nameLabel.classList.add("scrolling");
423
+ }
424
+ });
425
+ }
426
+
427
+ // Disk operations - delegate to disk-operations module
428
+
429
+ async loadDisk(driveNum, file) {
430
+ const drive = this.drives[driveNum];
431
+ await loadDisk({
432
+ wasmModule: this.wasmModule,
433
+ drive,
434
+ driveNum,
435
+ file,
436
+ onSuccess: (filename) => {
437
+ this.setDiskName(driveNum, filename);
438
+ if (this.onDiskLoaded) this.onDiskLoaded(driveNum, filename);
439
+ },
440
+ onError: (error) => showToast(error, "error"),
441
+ });
442
+ }
443
+
444
+ insertBlankDisk(driveNum) {
445
+ const drive = this.drives[driveNum];
446
+ insertBlankDisk({
447
+ wasmModule: this.wasmModule,
448
+ drive,
449
+ driveNum,
450
+ onSuccess: (filename) => this.setDiskName(driveNum, filename),
451
+ onError: (error) => showToast(error, "error"),
452
+ });
453
+ }
454
+
455
+ async ejectDisk(driveNum) {
456
+ const drive = this.drives[driveNum];
457
+ await ejectDisk({
458
+ wasmModule: this.wasmModule,
459
+ drive,
460
+ driveNum,
461
+ onEject: () => {
462
+ this.setDiskName(driveNum, "No Disk");
463
+ this._resetDriveVisuals(driveNum);
464
+ },
465
+ });
466
+ }
467
+
468
+ performEject(driveNum) {
469
+ const drive = this.drives[driveNum];
470
+ performEject({
471
+ wasmModule: this.wasmModule,
472
+ drive,
473
+ driveNum,
474
+ onEject: () => {
475
+ this.setDiskName(driveNum, "No Disk");
476
+ this._resetDriveVisuals(driveNum);
477
+ },
478
+ });
479
+ }
480
+
481
+ // Drive state and LED updates
482
+
483
+ updateLEDs() {
484
+ // Update drive images and track display based on motor state
485
+ if (!this.wasmModule._getDiskMotorOn) return;
486
+
487
+ const selectedDrive = this.wasmModule._getSelectedDrive
488
+ ? this.wasmModule._getSelectedDrive()
489
+ : 0;
490
+
491
+ // Check if any motor is running for motor sound
492
+ const anyMotorOn =
493
+ this.wasmModule._getDiskMotorOn(0) || this.wasmModule._getDiskMotorOn(1);
494
+ if (anyMotorOn && !this.sounds.motorRunning) {
495
+ this.sounds.startMotorSound();
496
+ } else if (!anyMotorOn && this.sounds.motorRunning) {
497
+ this.sounds.stopMotorSound();
498
+ }
499
+
500
+ for (let driveNum = 0; driveNum < 2; driveNum++) {
501
+ const drive = this.drives[driveNum];
502
+ const motorOn = this.wasmModule._getDiskMotorOn(driveNum);
503
+ const isActive = motorOn && driveNum === selectedDrive;
504
+ const hasDisk = drive.filename !== null;
505
+
506
+ // Update track display and check for seek
507
+ let track = 0;
508
+ let quarterTrack = 0;
509
+ let isWriteMode = false;
510
+ if (drive.trackLabel) {
511
+ if (hasDisk && this.wasmModule._getDiskTrack) {
512
+ track = this.wasmModule._getDiskTrack(driveNum);
513
+ drive.trackLabel.textContent = `T${track.toString().padStart(2, "0")}`;
514
+
515
+ // Check for track change and play seek sound (only on whole track changes)
516
+ if (isActive && drive.lastTrack >= 0 && track !== drive.lastTrack) {
517
+ this.sounds.playSeekSound();
518
+ }
519
+ drive.lastTrack = track;
520
+
521
+ // Highlight when motor is on and this drive is selected
522
+ if (isActive) {
523
+ drive.trackLabel.classList.add("active");
524
+ } else {
525
+ drive.trackLabel.classList.remove("active");
526
+ }
527
+
528
+ // Update heatmap tracking
529
+ if (isActive && drive.trackAccessCounts) {
530
+ const clampedTrack = Math.min(track, 34);
531
+ drive.trackAccessCounts[clampedTrack]++;
532
+ if (drive.trackAccessCounts[clampedTrack] > drive.maxAccessCount) {
533
+ drive.maxAccessCount = drive.trackAccessCounts[clampedTrack];
534
+ }
535
+ }
536
+
537
+ // Decay track highlights every 200ms
538
+ const now = performance.now();
539
+ if (drive.trackAccessCounts && now - drive.lastDecayTime > 100) {
540
+ drive.lastDecayTime = now;
541
+ let newMax = 0;
542
+ for (let t = 0; t < 35; t++) {
543
+ if (drive.trackAccessCounts[t] > 0) {
544
+ drive.trackAccessCounts[t] = Math.floor(
545
+ drive.trackAccessCounts[t] * 0.8,
546
+ );
547
+ if (drive.trackAccessCounts[t] > newMax) {
548
+ newMax = drive.trackAccessCounts[t];
549
+ }
550
+ }
551
+ }
552
+ drive.maxAccessCount = newMax;
553
+ }
554
+
555
+ // Get head position
556
+ if (this.wasmModule._getDiskHeadPosition) {
557
+ quarterTrack = this.wasmModule._getDiskHeadPosition(driveNum);
558
+ drive.lastHeadPosition = quarterTrack;
559
+ }
560
+
561
+ // Get write mode
562
+ if (this.wasmModule._getDiskWriteMode) {
563
+ isWriteMode = this.wasmModule._getDiskWriteMode(driveNum);
564
+ }
565
+ } else {
566
+ drive.trackLabel.textContent = "T--";
567
+ drive.trackLabel.classList.remove("active");
568
+ drive.lastTrack = -1;
569
+ }
570
+ }
571
+
572
+ // Update surface renderer (skip when window is hidden)
573
+ if (drive.surfaceRenderer && this.drivesWindowVisible) {
574
+ const diskColor =
575
+ hasDisk && drive.filename
576
+ ? this._getStickerColor(drive.filename)
577
+ : null;
578
+
579
+ drive.surfaceRenderer.update({
580
+ hasDisk,
581
+ isActive,
582
+ isWriteMode,
583
+ quarterTrack,
584
+ track,
585
+ trackAccessCounts: drive.trackAccessCounts,
586
+ maxAccessCount: drive.maxAccessCount,
587
+ diskColor,
588
+ timestamp: performance.now(),
589
+ });
590
+ }
591
+ }
592
+ }
593
+
594
+ // Sound control - delegate to DriveSounds
595
+
596
+ setSeekSoundEnabled(enabled) {
597
+ this.sounds.setSeekSoundEnabled(enabled);
598
+ }
599
+
600
+ setSeekVolume(volume) {
601
+ this.sounds.setSeekVolume(volume);
602
+ }
603
+
604
+ setMotorSoundEnabled(enabled) {
605
+ this.sounds.setMotorSoundEnabled(enabled);
606
+ }
607
+
608
+ setMotorVolume(volume) {
609
+ this.sounds.setMotorVolume(volume);
610
+ }
611
+
612
+ setMasterVolume(volume) {
613
+ this.sounds.setMasterVolume(volume);
614
+ }
615
+
616
+ // Recent disks dropdown
617
+
618
+ /**
619
+ * Toggle the recent disks dropdown for a drive
620
+ */
621
+ async toggleRecentDropdown(driveNum) {
622
+ const drive = this.drives[driveNum];
623
+ if (!drive.recentDropdown) return;
624
+
625
+ // Close any other open dropdown
626
+ if (this.activeDropdown && this.activeDropdown !== drive.recentDropdown) {
627
+ this.closeRecentDropdown();
628
+ }
629
+
630
+ if (drive.recentDropdown.classList.contains("open")) {
631
+ this.closeRecentDropdown();
632
+ } else {
633
+ await this.populateRecentDropdown(driveNum);
634
+ drive.recentDropdown.classList.add("open");
635
+ this.activeDropdown = drive.recentDropdown;
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Close the currently open dropdown
641
+ */
642
+ closeRecentDropdown() {
643
+ if (this.activeDropdown) {
644
+ this.activeDropdown.classList.remove("open");
645
+ this.activeDropdown = null;
646
+ }
647
+ this.refocusCanvas();
648
+ }
649
+
650
+ /**
651
+ * Populate the recent disks dropdown
652
+ */
653
+ async populateRecentDropdown(driveNum) {
654
+ const drive = this.drives[driveNum];
655
+ if (!drive.recentDropdown) return;
656
+
657
+ const recentDisks = await getRecentDisks(driveNum);
658
+
659
+ drive.recentDropdown.innerHTML = "";
660
+
661
+ if (recentDisks.length === 0) {
662
+ const emptyItem = document.createElement("div");
663
+ emptyItem.className = "recent-item empty";
664
+ emptyItem.textContent = "No recent disks";
665
+ drive.recentDropdown.appendChild(emptyItem);
666
+ } else {
667
+ for (const disk of recentDisks) {
668
+ const item = document.createElement("div");
669
+ item.className = "recent-item";
670
+ item.textContent = disk.filename;
671
+ item.title = disk.filename;
672
+ item.addEventListener("click", (e) => {
673
+ e.stopPropagation();
674
+ this.loadRecentDiskInDrive(driveNum, disk.id);
675
+ });
676
+ drive.recentDropdown.appendChild(item);
677
+ }
678
+
679
+ // Add separator and clear option
680
+ const separator = document.createElement("div");
681
+ separator.className = "recent-separator";
682
+ drive.recentDropdown.appendChild(separator);
683
+
684
+ const clearItem = document.createElement("div");
685
+ clearItem.className = "recent-item recent-clear";
686
+ clearItem.textContent = "Clear Recent";
687
+ clearItem.addEventListener("click", async (e) => {
688
+ e.stopPropagation();
689
+ await clearRecentDisks(driveNum);
690
+ this.closeRecentDropdown();
691
+ });
692
+ drive.recentDropdown.appendChild(clearItem);
693
+ }
694
+
695
+ // Append library section
696
+ await this._appendLibrarySection(drive.recentDropdown, driveNum, "floppy");
697
+ }
698
+
699
+ /**
700
+ * Fetch a library image with IndexedDB caching
701
+ */
702
+ async _getLibraryImageData(entry) {
703
+ try {
704
+ const cached = await libraryCacheDb.get(CACHE_STORE, entry.id);
705
+ if (cached) return new Uint8Array(cached.data);
706
+ } catch (err) {
707
+ console.warn("Library cache read failed:", err);
708
+ }
709
+
710
+ const resp = await fetch(`/disks/${entry.file}`);
711
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
712
+ const arrayBuffer = await resp.arrayBuffer();
713
+ const data = new Uint8Array(arrayBuffer);
714
+
715
+ try {
716
+ await libraryCacheDb.put(CACHE_STORE, {
717
+ id: entry.id,
718
+ file: entry.file,
719
+ data: arrayBuffer,
720
+ });
721
+ } catch (err) {
722
+ console.warn("Library cache write failed:", err);
723
+ }
724
+
725
+ return data;
726
+ }
727
+
728
+ /**
729
+ * Append library entries to a recent dropdown
730
+ */
731
+ async _appendLibrarySection(dropdown, driveNum, type) {
732
+ try {
733
+ const resp = await fetch("/disks/library.json");
734
+ if (!resp.ok) return;
735
+ const library = await resp.json();
736
+ const entries = library.filter((e) => e.type === type);
737
+ if (entries.length === 0) return;
738
+
739
+ const separator = document.createElement("div");
740
+ separator.className = "recent-separator";
741
+ dropdown.appendChild(separator);
742
+
743
+ const label = document.createElement("div");
744
+ label.className = "recent-section-label";
745
+ label.textContent = "Library";
746
+ dropdown.appendChild(label);
747
+
748
+ for (const entry of entries) {
749
+ const item = document.createElement("div");
750
+ item.className = "recent-item";
751
+ item.textContent = entry.name;
752
+ item.title = entry.description || entry.name;
753
+ item.addEventListener("click", async (e) => {
754
+ e.stopPropagation();
755
+ this.closeRecentDropdown();
756
+ try {
757
+ const data = await this._getLibraryImageData(entry);
758
+ const drive = this.drives[driveNum];
759
+ loadDiskFromData({
760
+ wasmModule: this.wasmModule,
761
+ drive,
762
+ driveNum,
763
+ filename: entry.file,
764
+ data,
765
+ onSuccess: (filename) => {
766
+ this.setDiskName(driveNum, filename);
767
+ if (this.onDiskLoaded) this.onDiskLoaded(driveNum, filename);
768
+ },
769
+ onError: (error) => showToast(error, "error"),
770
+ });
771
+ saveDiskToStorage(driveNum, entry.file, data);
772
+ addToRecentDisks(driveNum, entry.file, data);
773
+ } catch (err) {
774
+ console.error(`Failed to load ${entry.file}:`, err);
775
+ showToast(`Failed to load ${entry.name}`, "error");
776
+ }
777
+ });
778
+ dropdown.appendChild(item);
779
+ }
780
+ } catch (err) {
781
+ console.error("Failed to load library:", err);
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Load a recent disk into a drive
787
+ */
788
+ async loadRecentDiskInDrive(driveNum, diskId) {
789
+ this.closeRecentDropdown();
790
+
791
+ const diskData = await loadRecentDisk(diskId);
792
+ if (!diskData) {
793
+ console.error("Failed to load recent disk");
794
+ return;
795
+ }
796
+
797
+ const drive = this.drives[driveNum];
798
+ loadDiskFromData({
799
+ wasmModule: this.wasmModule,
800
+ drive,
801
+ driveNum,
802
+ filename: diskData.filename,
803
+ data: diskData.data,
804
+ onSuccess: (filename) => {
805
+ this.setDiskName(driveNum, filename);
806
+ if (this.onDiskLoaded) this.onDiskLoaded(driveNum, filename);
807
+ },
808
+ onError: (error) => showToast(error, "error"),
809
+ });
810
+
811
+ // Update access time by re-adding to recent list
812
+ addToRecentDisks(driveNum, diskData.filename, diskData.data);
813
+ }
814
+
815
+ /**
816
+ * Sync UI with emulator state after a state restore
817
+ * This updates the drive displays to show any disks loaded from the state snapshot
818
+ */
819
+ syncWithEmulatorState() {
820
+ for (let driveNum = 0; driveNum < 2; driveNum++) {
821
+ const drive = this.drives[driveNum];
822
+ const hasDisk = this.wasmModule._isDiskInserted(driveNum);
823
+
824
+ if (hasDisk) {
825
+ // Get filename from emulator
826
+ const filenamePtr = this.wasmModule._getDiskFilename(driveNum);
827
+ let filename = "Restored Disk";
828
+ if (filenamePtr) {
829
+ filename = this.wasmModule.UTF8ToString(filenamePtr);
830
+ }
831
+
832
+ drive.filename = filename;
833
+ if (drive.ejectBtn) drive.ejectBtn.disabled = false;
834
+ if (drive.browseBtn) drive.browseBtn.disabled = false;
835
+ this.setDiskName(driveNum, filename);
836
+ console.log(`Synced drive ${driveNum + 1}: ${filename}`);
837
+ } else {
838
+ // No disk in drive
839
+ drive.filename = null;
840
+ if (drive.ejectBtn) drive.ejectBtn.disabled = true;
841
+ if (drive.browseBtn) drive.browseBtn.disabled = true;
842
+ if (drive.input) drive.input.value = "";
843
+ this.setDiskName(driveNum, "No Disk");
844
+ }
845
+ }
846
+ }
847
+
848
+ // Visual enhancement methods
849
+
850
+ /**
851
+ * Reset visual enhancements for a drive on eject
852
+ */
853
+ _resetDriveVisuals(driveNum) {
854
+ const drive = this.drives[driveNum];
855
+
856
+ // Reset track access data
857
+ if (drive.trackAccessCounts) {
858
+ drive.trackAccessCounts.fill(0);
859
+ drive.maxAccessCount = 0;
860
+ drive.lastHeadPosition = -1;
861
+ }
862
+
863
+ // Reset surface renderer
864
+ if (drive.surfaceRenderer) {
865
+ drive.surfaceRenderer.reset();
866
+ }
867
+ }
868
+
869
+ /**
870
+ * Derive a sticker color from a filename hash
871
+ * Returns one of 8 vintage label colors
872
+ */
873
+ _getStickerColor(filename) {
874
+ const colors = [
875
+ "#f5f0d0", // cream
876
+ "#e8d8a0", // manila
877
+ "#c8e6c0", // pale green
878
+ "#b8d4e8", // pale blue
879
+ "#f0c0c8", // pink
880
+ "#f0e8a0", // yellow
881
+ "#d0c8e8", // lavender
882
+ "#f0ece8", // white
883
+ ];
884
+ let hash = 0;
885
+ for (let i = 0; i < filename.length; i++) {
886
+ hash = ((hash << 5) - hash + filename.charCodeAt(i)) | 0;
887
+ }
888
+ return colors[Math.abs(hash) % colors.length];
889
+ }
890
+ }