synabun 1.3.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 (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,147 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Internationalization
3
+ // Lightweight runtime i18n: loads JSON translations at boot,
4
+ // exposes t() / tp() for all modules.
5
+ // ═══════════════════════════════════════════
6
+
7
+ import { storage } from './storage.js';
8
+
9
+ let _messages = {};
10
+ let _locale = 'en';
11
+ let _ready = false;
12
+
13
+ // ── Supported locales (grows as translations are added) ──
14
+ export const SUPPORTED_LOCALES = ['en'];
15
+
16
+ // ── Locale metadata (for language switcher UI) ──
17
+ export const LOCALE_NAMES = {
18
+ en: 'English',
19
+ };
20
+
21
+ // ═══════════════════════════════════════════
22
+ // CORE API
23
+ // ═══════════════════════════════════════════
24
+
25
+ /**
26
+ * Resolve a dot-separated key path against a nested object.
27
+ * t('settings.server.status') → messages.settings.server.status
28
+ */
29
+ function resolve(obj, path) {
30
+ return path.split('.').reduce((o, k) => o?.[k], obj);
31
+ }
32
+
33
+ /**
34
+ * Translate a key, with optional parameter interpolation.
35
+ *
36
+ * @param {string} key Dot-separated key path (e.g. 'nav.view')
37
+ * @param {Object} [params] Interpolation values: "Hello {name}" + { name: 'Dan' }
38
+ * @returns {string} Translated string, or the key itself if missing
39
+ *
40
+ * @example
41
+ * t('common.save') // "Save"
42
+ * t('explorer.importanceTooltip', { n: 8 }) // "Importance: 8/10"
43
+ */
44
+ export function t(key, params) {
45
+ let val = resolve(_messages, key);
46
+ if (val === undefined) {
47
+ if (_ready) console.warn(`[i18n] Missing key: ${key}`);
48
+ return key;
49
+ }
50
+ if (params && typeof val === 'string') {
51
+ val = val.replace(/\{(\w+)\}/g, (_, k) => (params[k] != null ? params[k] : `{${k}}`));
52
+ }
53
+ return val;
54
+ }
55
+
56
+ /**
57
+ * Pluralization helper. Resolves to key.one or key.other based on count.
58
+ *
59
+ * @param {string} key Base key path (e.g. 'search.resultCount')
60
+ * @param {number} count The number to pluralize on
61
+ * @param {Object} [params] Extra interpolation values (count is auto-included)
62
+ * @returns {string}
63
+ *
64
+ * @example
65
+ * // en.json: { "search": { "resultCount": { "one": "1 result", "other": "{count} results" } } }
66
+ * tp('search.resultCount', 1) // "1 result"
67
+ * tp('search.resultCount', 42) // "42 results"
68
+ */
69
+ export function tp(key, count, params) {
70
+ const plural = count === 1 ? 'one' : 'other';
71
+ return t(`${key}.${plural}`, { count, ...params });
72
+ }
73
+
74
+ /**
75
+ * Get the current locale code (e.g. 'en', 'es').
76
+ * Use this for Intl APIs: date.toLocaleDateString(getLocale(), ...)
77
+ */
78
+ export function getLocale() {
79
+ return _locale;
80
+ }
81
+
82
+ /**
83
+ * Check if translations have been loaded.
84
+ */
85
+ export function isReady() {
86
+ return _ready;
87
+ }
88
+
89
+ // ═══════════════════════════════════════════
90
+ // INITIALIZATION
91
+ // ═══════════════════════════════════════════
92
+
93
+ /**
94
+ * Detect the user's preferred locale.
95
+ * Priority: localStorage explicit choice → browser language → 'en'
96
+ */
97
+ function detectLocale() {
98
+ // 1. Explicit user preference
99
+ const saved = storage.getItem('synabun-locale');
100
+ if (saved && SUPPORTED_LOCALES.includes(saved)) return saved;
101
+ // 2. Browser language
102
+ const browser = (navigator.language || 'en').slice(0, 2).toLowerCase();
103
+ if (SUPPORTED_LOCALES.includes(browser)) return browser;
104
+ // 3. Default
105
+ return 'en';
106
+ }
107
+
108
+ /**
109
+ * Load translations. Call this ONCE at app boot, BEFORE any UI modules init.
110
+ * Typically the first thing in the entry point's top-level await.
111
+ *
112
+ * @param {string} [locale] Force a specific locale (skips detection)
113
+ */
114
+ export async function initI18n(locale) {
115
+ _locale = locale || detectLocale();
116
+ try {
117
+ const resp = await fetch(`/i18n/${_locale}.json`);
118
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
119
+ _messages = await resp.json();
120
+ } catch (err) {
121
+ // Fallback to English if the requested locale file fails
122
+ if (_locale !== 'en') {
123
+ console.warn(`[i18n] Failed to load '${_locale}', falling back to 'en'`);
124
+ _locale = 'en';
125
+ try {
126
+ const resp = await fetch('/i18n/en.json');
127
+ if (resp.ok) _messages = await resp.json();
128
+ } catch {
129
+ console.error('[i18n] Failed to load English fallback');
130
+ }
131
+ } else {
132
+ console.error('[i18n] Failed to load English translations:', err);
133
+ }
134
+ }
135
+ _ready = true;
136
+ document.documentElement.lang = _locale;
137
+ }
138
+
139
+ /**
140
+ * Switch to a different locale. Persists to localStorage and reloads.
141
+ * @param {string} locale
142
+ */
143
+ export function setLocale(locale) {
144
+ if (!SUPPORTED_LOCALES.includes(locale)) return;
145
+ storage.setItem('synabun-locale', locale);
146
+ window.location.reload();
147
+ }
@@ -0,0 +1,82 @@
1
+ // ═══════════════════════════════════════════
2
+ // REGISTRY — feature registry for variant capabilities
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Each variant (3D / 2D) registers its identity, capabilities, and UI
6
+ // extensions on boot. Shared UI reads the registry to decide what to render.
7
+
8
+ const _registry = {
9
+ variant: null, // '3d' | '2d' | future variants
10
+ capabilities: new Set(), // e.g. 'camera-hud', 'minimap', 'hulls', 'lasso', 'controls-panel'
11
+ navbarButtons: [], // { zone, order, html, init } (legacy, kept for compat)
12
+ menuItems: [], // { menu, order, type, id, label, shortcut?, init }
13
+ helpSections: [], // { order, html }
14
+ settingsTabs: [], // { id, label, icon, builder, afterRender }
15
+ keyboardShortcuts: [], // { key, description, handler }
16
+ };
17
+
18
+ // ── Registration API ──
19
+
20
+ /**
21
+ * Register the active variant and its capabilities.
22
+ * Called once at variant boot time.
23
+ * @param {{ variant: string, capabilities?: string[] }} config
24
+ */
25
+ export function registerVariant(config) {
26
+ _registry.variant = config.variant;
27
+ _registry.capabilities = new Set(config.capabilities || []);
28
+ }
29
+
30
+ /**
31
+ * Register an extra button for the top navbar.
32
+ * @param {{ zone: string, order: number, html: string, init: Function }} spec
33
+ */
34
+ export function registerNavbarButton(spec) {
35
+ _registry.navbarButtons.push(spec);
36
+ _registry.navbarButtons.sort((a, b) => a.order - b.order);
37
+ }
38
+
39
+ /**
40
+ * Register a menu item for the OS-style menubar.
41
+ * @param {{ menu: string, order: number, type: string, id: string, label: string, shortcut?: string, init?: Function }} spec
42
+ */
43
+ export function registerMenuItem(spec) {
44
+ _registry.menuItems.push(spec);
45
+ _registry.menuItems.sort((a, b) => a.order - b.order);
46
+ }
47
+
48
+ /**
49
+ * Register an extra section for the help modal.
50
+ * @param {{ order: number, html: string }} spec
51
+ */
52
+ export function registerHelpSection(spec) {
53
+ _registry.helpSections.push(spec);
54
+ _registry.helpSections.sort((a, b) => a.order - b.order);
55
+ }
56
+
57
+ /**
58
+ * Register an extra tab for the settings panel.
59
+ * @param {{ id: string, label: string, icon: string, builder: Function, afterRender?: Function }} spec
60
+ */
61
+ export function registerSettingsTab(spec) {
62
+ _registry.settingsTabs.push(spec);
63
+ }
64
+
65
+ /**
66
+ * Register a keyboard shortcut (for help display and central handler).
67
+ * @param {{ key: string, description: string, handler: Function }} spec
68
+ */
69
+ export function registerKeyboardShortcut(spec) {
70
+ _registry.keyboardShortcuts.push(spec);
71
+ }
72
+
73
+ // ── Query API ──
74
+
75
+ export function getVariant() { return _registry.variant; }
76
+ export function hasCapability(name) { return _registry.capabilities.has(name); }
77
+ export function getNavbarButtons() { return _registry.navbarButtons; }
78
+ export function getMenuItems() { return _registry.menuItems; }
79
+ export function getHelpSections() { return _registry.helpSections; }
80
+ export function getSettingsTabs() { return _registry.settingsTabs; }
81
+ export function getKeyboardShortcuts() { return _registry.keyboardShortcuts; }
82
+ export function getRegistry() { return _registry; }
@@ -0,0 +1,86 @@
1
+ // ═══════════════════════════════════════════
2
+ // STATE — shared reactive state store + event bus
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // All UI components read/write this object.
6
+ // Variants (3D / 2D) listen for changes via the event bus.
7
+ // Variant-specific state (graph, bloom, frustum, etc.) stays in the variant.
8
+
9
+ export const state = {
10
+ // ── Data (loaded from API) ──
11
+ allNodes: [],
12
+ allLinks: [],
13
+ categoryMetadata: {}, // { name: { description, color, parent, is_parent, logo } }
14
+ allCategoryNames: [], // ordered list of category names from server
15
+ categoryDescriptions: {},// { name: description }
16
+
17
+ // ── Selection ──
18
+ selectedNodeId: null,
19
+ hoveredNodeId: null,
20
+ focusedNodeId: null,
21
+
22
+ // ── Search ──
23
+ searchResults: null, // null = no active search, Set of IDs = active search
24
+ searchQuery: '',
25
+
26
+ // ── Category visibility ──
27
+ activeCategories: new Set(),
28
+
29
+ // ── Multi-select ──
30
+ multiSelected: new Set(),
31
+
32
+ // ── UI toggles ──
33
+ labelsVisible: true,
34
+ labelSizeMultiplier: 1.0,
35
+
36
+ // ── Link mode ──
37
+ linkMode: 'off', // 'off' | 'intra' | 'all'
38
+ linkTypeFilter: 'all',
39
+
40
+ // ── Grid snap ──
41
+ gridSnap: false,
42
+ gridSize: 20,
43
+
44
+ // ── Terminal links ──
45
+ linkedSessionIds: new Set(), // session IDs currently in an active link
46
+ };
47
+
48
+
49
+ // ═══════════════════════════════════════════
50
+ // EVENT BUS — lightweight pub/sub
51
+ // ═══════════════════════════════════════════
52
+
53
+ const _listeners = {};
54
+
55
+ /**
56
+ * Emit an event to all registered listeners.
57
+ * @param {string} event Event name (e.g. 'selection-changed', 'data-loaded')
58
+ * @param {*} [data] Payload passed to each listener
59
+ */
60
+ export function emit(event, data) {
61
+ const fns = _listeners[event];
62
+ if (fns) fns.forEach(fn => fn(data));
63
+ }
64
+
65
+ /**
66
+ * Subscribe to an event. Returns an unsubscribe function.
67
+ * @param {string} event Event name
68
+ * @param {Function} fn Listener
69
+ * @returns {Function} Unsubscribe function
70
+ */
71
+ export function on(event, fn) {
72
+ if (!_listeners[event]) _listeners[event] = [];
73
+ _listeners[event].push(fn);
74
+ return () => { _listeners[event] = _listeners[event].filter(f => f !== fn); };
75
+ }
76
+
77
+ /**
78
+ * Remove a specific listener for an event.
79
+ * @param {string} event Event name
80
+ * @param {Function} fn Listener to remove
81
+ */
82
+ export function off(event, fn) {
83
+ if (_listeners[event]) {
84
+ _listeners[event] = _listeners[event].filter(f => f !== fn);
85
+ }
86
+ }
@@ -0,0 +1,150 @@
1
+ // ═══════════════════════════════════════════
2
+ // STORAGE — Server-synced persistent state
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Drop-in replacement for localStorage calls.
6
+ // Data persists to data/ui-state.json on the server.
7
+ //
8
+ // Self-hydrating: the module-level `await` ensures any module that
9
+ // imports from storage.js gets a populated cache before its own
10
+ // body executes. This is critical for modules like gfx.js that call
11
+ // loadGfxConfig() at import time.
12
+ //
13
+ // The in-memory cache ensures synchronous reads (zero latency).
14
+ // Writes are async but invisible to callers.
15
+
16
+ const _cache = {};
17
+ let _dirty = {};
18
+ let _flushTimer = null;
19
+ let _hydrated = false;
20
+ const DEBOUNCE_MS = 500;
21
+
22
+ // ── Hydrate from server ──────────────────────────────
23
+
24
+ async function _hydrate() {
25
+ let serverData = null;
26
+ try {
27
+ const res = await fetch('/api/ui-state');
28
+ if (res.ok) serverData = await res.json();
29
+ } catch { /* server unreachable */ }
30
+
31
+ if (serverData && Object.keys(serverData).filter(k => !k.startsWith('_')).length > 0) {
32
+ // Normal boot: hydrate from server file
33
+ for (const [key, value] of Object.entries(serverData)) {
34
+ if (key.startsWith('_')) continue;
35
+ _cache[key] = typeof value === 'string' ? value : JSON.stringify(value);
36
+ }
37
+ } else {
38
+ // First run or server down: import from localStorage
39
+ console.info('[storage] Migrating localStorage to server...');
40
+ _migrateFromLocalStorage();
41
+ // Build dirty set from everything we imported
42
+ for (const [key, val] of Object.entries(_cache)) {
43
+ try { _dirty[key] = JSON.parse(val); } catch { _dirty[key] = val; }
44
+ }
45
+ if (Object.keys(_dirty).length > 0) {
46
+ await _flushToServer();
47
+ console.info('[storage] Migration complete. Data saved to data/ui-state.json');
48
+ }
49
+ }
50
+ _hydrated = true;
51
+ }
52
+
53
+ // Self-hydrate at module load — blocks dependents until cache is ready
54
+ await _hydrate();
55
+
56
+ // ── Drop-in API (mirrors localStorage) ───────────────
57
+
58
+ export const storage = {
59
+ getItem(key) {
60
+ return _cache[key] ?? null;
61
+ },
62
+
63
+ setItem(key, value) {
64
+ const strValue = String(value);
65
+ if (_cache[key] === strValue) return; // no-op if unchanged
66
+ _cache[key] = strValue;
67
+ // Parse back to native type for the server (avoids double-encoding JSON)
68
+ try { _dirty[key] = JSON.parse(strValue); } catch { _dirty[key] = strValue; }
69
+ _scheduleDebouncedFlush();
70
+ },
71
+
72
+ removeItem(key) {
73
+ if (!(key in _cache)) return;
74
+ delete _cache[key];
75
+ _dirty[key] = null; // null signals deletion to the server
76
+ _scheduleDebouncedFlush();
77
+ },
78
+ };
79
+
80
+ // ── Debounced flush to server ────────────────────────
81
+
82
+ function _scheduleDebouncedFlush() {
83
+ if (_flushTimer) clearTimeout(_flushTimer);
84
+ _flushTimer = setTimeout(_flushToServer, DEBOUNCE_MS);
85
+ }
86
+
87
+ async function _flushToServer() {
88
+ const payload = { ..._dirty };
89
+ _dirty = {};
90
+ _flushTimer = null;
91
+ if (Object.keys(payload).length === 0) return;
92
+ try {
93
+ const res = await fetch('/api/ui-state', {
94
+ method: 'PATCH',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify(payload),
97
+ });
98
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
99
+ } catch (err) {
100
+ console.warn('[storage] Flush failed:', err.message, '— will retry');
101
+ // Re-queue failed keys (don't overwrite newer dirty values)
102
+ for (const [k, v] of Object.entries(payload)) {
103
+ if (!(k in _dirty)) _dirty[k] = v;
104
+ }
105
+ _scheduleDebouncedFlush();
106
+ }
107
+ }
108
+
109
+ // ── Flush on page unload (sendBeacon for reliability) ──
110
+
111
+ window.addEventListener('beforeunload', () => {
112
+ if (_flushTimer) {
113
+ clearTimeout(_flushTimer);
114
+ _flushTimer = null;
115
+ }
116
+ // Merge any pending dirty keys
117
+ const payload = { ..._dirty };
118
+ _dirty = {};
119
+ if (Object.keys(payload).length === 0) return;
120
+ navigator.sendBeacon(
121
+ '/api/ui-state',
122
+ new Blob([JSON.stringify(payload)], { type: 'application/json' })
123
+ );
124
+ });
125
+
126
+ // ── Migration helper ─────────────────────────────────
127
+
128
+ function _migrateFromLocalStorage() {
129
+ for (let i = 0; i < localStorage.length; i++) {
130
+ const key = localStorage.key(i);
131
+ if (key && (key.startsWith('neural-') || key.startsWith('synabun-'))) {
132
+ _cache[key] = localStorage.getItem(key);
133
+ }
134
+ }
135
+ }
136
+
137
+ // ── Utilities ────────────────────────────────────────
138
+
139
+ /** Force an immediate flush (useful for testing or explicit save actions) */
140
+ export async function flushStorage() {
141
+ if (_flushTimer) {
142
+ clearTimeout(_flushTimer);
143
+ _flushTimer = null;
144
+ }
145
+ await _flushToServer();
146
+ }
147
+
148
+ export function isHydrated() {
149
+ return _hydrated;
150
+ }