youssefreda9 commited on
Commit
e5f6c27
·
1 Parent(s): fd0c8f4

Phase 7.1: Cleanup & Stabilization Sprint

Browse files

- NEW: shared/constants.js — single source of truth for all config
- NEW: shared/hash.js — single FNV-1a hash (was duplicated 3x)
- SIMPLIFIED: analysis-controller.js (320→131 lines)
- Removed: cache, retry, timeout, AbortController
- Kept: debounce (2-tier), validate, sendMessage
- SIMPLIFIED: background.js (248→170 lines)
- Uses importScripts for constants + hash
- Version from chrome.runtime.getManifest()
- No hardcoded API URL
- SIMPLIFIED: content-inline.js (638→392 lines)
- Removed BayanState dependency (inline local vars)
- Overlay-only rendering for ALL fields (no innerHTML on user content)
- Fixed listener tracking leak (F10)
- FIXED: config.js derives from BAYAN constants, no hardcoded version
- FIXED: manifest.json
- Removed dead content.js
- Removed bayan-state.js from content_scripts
- Added minimum_chrome_version: 114
- Changed matches to https/http only (was <all_urls>)
- Version bumped to 2.1.0
- Updated popup.html + sidepanel.html script load order

Audit fixes: F01 F02 F03 F04 F05 F07 F08 F10 F25
Net: -458 lines, 3 duplicate systems eliminated

extension/_locales/ar/messages.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extName": {
3
+ "message": "بيان — مساعد الكتابة العربية",
4
+ "description": "Extension name shown in Chrome"
5
+ },
6
+ "extDescription": {
7
+ "message": "تصحيح إملائي ونحوي وترقيمي للنصوص العربية مع تلخيص ذكي",
8
+ "description": "Extension description shown in Chrome Web Store"
9
+ },
10
+ "contextMenuCorrect": {
11
+ "message": "تصحيح مع بيان",
12
+ "description": "Context menu item for correcting selected text"
13
+ },
14
+ "contextMenuSummarize": {
15
+ "message": "تلخيص مع بيان",
16
+ "description": "Context menu item for summarizing selected text"
17
+ }
18
+ }
extension/background.js ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan — Background Service Worker (Phase 7.1 Simplified)
3
+ *
4
+ * Single network boundary. Owns:
5
+ * 1. Context menu → side panel (Phase 5)
6
+ * 2. API calls with cache + timeout + retry (Phase 7)
7
+ *
8
+ * Uses: BAYAN (shared/constants.js), bayanHash (shared/hash.js)
9
+ * loaded via importScripts().
10
+ */
11
+
12
+ importScripts('shared/constants.js', 'shared/hash.js');
13
+
14
+ // ── Context constants ──
15
+ const ACTIONS = { CORRECT: 'correct', SUMMARIZE: 'summarize' };
16
+ const CONTEXT_KEYS = ['contextAction', 'contextText', 'contextTimestamp'];
17
+ const SIDE_PANEL_PATH = 'sidepanel/sidepanel.html';
18
+
19
+ // ── Deduplication cache ──
20
+ const _cache = new Map();
21
+
22
+ function cacheGet(text) {
23
+ const key = bayanHash(text);
24
+ const entry = _cache.get(key);
25
+ if (!entry) return null;
26
+ if (Date.now() - entry.ts > BAYAN.CACHE_TTL_MS) { _cache.delete(key); return null; }
27
+ if (entry.text !== text) return null;
28
+ return entry.data;
29
+ }
30
+
31
+ function cacheSet(text, data) {
32
+ const key = bayanHash(text);
33
+ if (_cache.size >= BAYAN.CACHE_MAX_ENTRIES) {
34
+ _cache.delete(_cache.keys().next().value);
35
+ }
36
+ _cache.set(key, { text, data, ts: Date.now() });
37
+ }
38
+
39
+ // ── Storage helper ──
40
+ function getStorage() {
41
+ return chrome.storage?.session || chrome.storage?.local;
42
+ }
43
+
44
+ // ══════════════════════════════════════════════════════════
45
+ // Context Menu
46
+ // ══════════════════════════════════════════════════════════
47
+
48
+ chrome.runtime.onInstalled.addListener(() => {
49
+ chrome.contextMenus.create({
50
+ id: 'bayan-correct',
51
+ title: chrome.i18n.getMessage('contextMenuCorrect') || 'تصحيح مع بيان',
52
+ contexts: ['selection'],
53
+ });
54
+ chrome.contextMenus.create({
55
+ id: 'bayan-summarize',
56
+ title: chrome.i18n.getMessage('contextMenuSummarize') || 'تلخيص مع بيان',
57
+ contexts: ['selection'],
58
+ });
59
+ });
60
+
61
+ chrome.contextMenus.onClicked.addListener((info, tab) => {
62
+ const selectedText = info.selectionText?.trim();
63
+ if (!selectedText) return;
64
+
65
+ let action = null;
66
+ if (info.menuItemId === 'bayan-correct') action = ACTIONS.CORRECT;
67
+ if (info.menuItemId === 'bayan-summarize') action = ACTIONS.SUMMARIZE;
68
+ if (!action) return;
69
+
70
+ // Open side panel IMMEDIATELY (preserve user gesture)
71
+ if (chrome.sidePanel) {
72
+ chrome.sidePanel.open({ windowId: tab.windowId }).catch(() => {
73
+ chrome.tabs.create({ url: chrome.runtime.getURL(SIDE_PANEL_PATH) });
74
+ });
75
+ } else {
76
+ chrome.tabs.create({ url: chrome.runtime.getURL(SIDE_PANEL_PATH) });
77
+ }
78
+
79
+ // Store context (async)
80
+ const storage = getStorage();
81
+ storage.set({
82
+ contextAction: action,
83
+ contextText: selectedText,
84
+ contextTimestamp: Date.now(),
85
+ });
86
+ setTimeout(() => storage.remove(CONTEXT_KEYS), 15000);
87
+ });
88
+
89
+ // ══════════════════════════════════════════════════════════
90
+ // Message Handler
91
+ // ══════════════════════════════════════════════════════════
92
+
93
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
94
+ if (message.type === 'HEALTH_CHECK') {
95
+ sendResponse({
96
+ status: 'ok',
97
+ version: chrome.runtime.getManifest().version,
98
+ cache: _cache.size,
99
+ });
100
+ return true;
101
+ }
102
+
103
+ if (message.type === 'CLEAR_CONTEXT') {
104
+ getStorage().remove(CONTEXT_KEYS);
105
+ sendResponse({ status: 'cleared' });
106
+ return true;
107
+ }
108
+
109
+ if (message.type === 'INLINE_ANALYZE') {
110
+ const text = message.text;
111
+ if (!text || text.length < BAYAN.MIN_TEXT_LENGTH) {
112
+ sendResponse({ error: 'Text too short' });
113
+ return true;
114
+ }
115
+ if (text.length > BAYAN.MAX_TEXT_LENGTH) {
116
+ sendResponse({ error: 'Text too long' });
117
+ return true;
118
+ }
119
+
120
+ // Cache hit → zero network
121
+ const cached = cacheGet(text);
122
+ if (cached) {
123
+ sendResponse({ data: cached, cached: true });
124
+ return true;
125
+ }
126
+
127
+ // Network call with retry
128
+ analyzeWithRetry(text)
129
+ .then((data) => {
130
+ if (data) cacheSet(text, data);
131
+ sendResponse({ data: data || null });
132
+ })
133
+ .catch((err) => {
134
+ console.warn('[Bayan BG] Analysis error:', err.message);
135
+ sendResponse({ error: err.message });
136
+ });
137
+
138
+ return true;
139
+ }
140
+
141
+ if (message.type === 'OPEN_SIDEPANEL') {
142
+ const storage = getStorage();
143
+ storage.set({
144
+ contextAction: ACTIONS.CORRECT,
145
+ contextText: message.text || '',
146
+ contextTimestamp: Date.now(),
147
+ });
148
+ if (chrome.sidePanel && sender.tab) {
149
+ chrome.sidePanel.open({ windowId: sender.tab.windowId }).catch(() => {
150
+ chrome.tabs.create({ url: chrome.runtime.getURL(SIDE_PANEL_PATH) });
151
+ });
152
+ }
153
+ sendResponse({ status: 'ok' });
154
+ return true;
155
+ }
156
+
157
+ return false;
158
+ });
159
+
160
+ // ══════════════════════════════════════════════════════════
161
+ // Network: fetch with timeout + 1 retry
162
+ // ══════════════════════════════════════════════════════════
163
+
164
+ async function analyzeWithRetry(text, attempt = 0) {
165
+ try {
166
+ return await fetchWithTimeout(text);
167
+ } catch (err) {
168
+ if (attempt < BAYAN.MAX_RETRIES) {
169
+ return analyzeWithRetry(text, attempt + 1);
170
+ }
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ async function fetchWithTimeout(text) {
176
+ const controller = new AbortController();
177
+ const timeout = setTimeout(() => controller.abort(), BAYAN.API_TIMEOUT_MS);
178
+
179
+ try {
180
+ const res = await fetch(`${BAYAN.API_BASE}/api/analyze`, {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ text }),
184
+ signal: controller.signal,
185
+ });
186
+ if (!res.ok) throw new Error(`API ${res.status}`);
187
+ return await res.json();
188
+ } finally {
189
+ clearTimeout(timeout);
190
+ }
191
+ }
extension/content-inline.css ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════
2
+ Bayan Inline Layer — Phase 6
3
+ All classes prefixed with bayan-il- to avoid conflicts.
4
+ Uses !important sparingly to override page styles.
5
+ ══════════════════════════════════════════════ */
6
+
7
+ /* ── Error highlights (contenteditable + overlay) ── */
8
+
9
+ .bayan-il-spelling {
10
+ background: rgba(239, 68, 68, 0.12) !important;
11
+ border-bottom: 2px solid #ef4444 !important;
12
+ border-radius: 2px !important;
13
+ cursor: pointer !important;
14
+ transition: background 150ms ease !important;
15
+ }
16
+ .bayan-il-spelling:hover {
17
+ background: rgba(239, 68, 68, 0.25) !important;
18
+ }
19
+
20
+ .bayan-il-grammar {
21
+ background: rgba(245, 158, 11, 0.12) !important;
22
+ border-bottom: 2px solid #f59e0b !important;
23
+ border-radius: 2px !important;
24
+ cursor: pointer !important;
25
+ transition: background 150ms ease !important;
26
+ }
27
+ .bayan-il-grammar:hover {
28
+ background: rgba(245, 158, 11, 0.25) !important;
29
+ }
30
+
31
+ .bayan-il-punctuation {
32
+ background: rgba(59, 130, 246, 0.12) !important;
33
+ border-bottom: 2px solid #3b82f6 !important;
34
+ border-radius: 2px !important;
35
+ cursor: pointer !important;
36
+ transition: background 150ms ease !important;
37
+ }
38
+ .bayan-il-punctuation:hover {
39
+ background: rgba(59, 130, 246, 0.25) !important;
40
+ }
41
+
42
+ /* ── Floating Action Button (FAB) ── */
43
+
44
+ .bayan-il-fab {
45
+ position: absolute;
46
+ z-index: 2147483646 !important;
47
+ width: 34px;
48
+ height: 34px;
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ background: #1a1a24 !important;
53
+ border: 1px solid #3a3a4d !important;
54
+ border-radius: 50% !important;
55
+ cursor: pointer !important;
56
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
57
+ opacity: 0;
58
+ transform: scale(0.7);
59
+ transition: opacity 200ms ease, transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1) !important;
60
+ pointer-events: none;
61
+ user-select: none !important;
62
+ }
63
+
64
+ .bayan-il-fab--visible {
65
+ opacity: 1 !important;
66
+ transform: scale(1) !important;
67
+ pointer-events: auto !important;
68
+ }
69
+
70
+ .bayan-il-fab:hover {
71
+ border-color: #6366f1 !important;
72
+ box-shadow: 0 2px 14px rgba(99, 102, 241, 0.3) !important;
73
+ }
74
+
75
+ /* ── Badge (on FAB) ── */
76
+
77
+ .bayan-il-badge {
78
+ position: absolute !important;
79
+ top: -4px;
80
+ right: -4px;
81
+ min-width: 16px;
82
+ height: 16px;
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: center;
86
+ font-size: 9px !important;
87
+ font-weight: 700 !important;
88
+ font-family: -apple-system, 'Segoe UI', sans-serif !important;
89
+ border-radius: 8px !important;
90
+ padding: 0 4px !important;
91
+ line-height: 1 !important;
92
+ border: 1.5px solid #1a1a24 !important;
93
+ color: white !important;
94
+ background: #6b6b80 !important;
95
+ }
96
+
97
+ .bayan-il-badge--clean {
98
+ background: #22c55e !important;
99
+ }
100
+
101
+ .bayan-il-badge--errors {
102
+ background: #ef4444 !important;
103
+ animation: bayan-il-pulse 600ms ease-out !important;
104
+ }
105
+
106
+ .bayan-il-badge--analyzing {
107
+ background: #6366f1 !important;
108
+ animation: bayan-il-spin 1s linear infinite !important;
109
+ }
110
+
111
+ .bayan-il-badge--paused {
112
+ background: #6b6b80 !important;
113
+ opacity: 0.7 !important;
114
+ }
115
+
116
+ @keyframes bayan-il-pulse {
117
+ 0% { transform: scale(1); }
118
+ 50% { transform: scale(1.3); }
119
+ 100% { transform: scale(1); }
120
+ }
121
+
122
+ @keyframes bayan-il-spin {
123
+ 0% { opacity: 0.5; }
124
+ 50% { opacity: 1; }
125
+ 100% { opacity: 0.5; }
126
+ }
127
+
128
+ /* ── Tooltip (suggestion popup) ── */
129
+
130
+ .bayan-il-tooltip {
131
+ position: absolute;
132
+ z-index: 2147483647 !important;
133
+ min-width: 200px;
134
+ max-width: 320px;
135
+ background: #1a1a24 !important;
136
+ border: 1px solid #3a3a4d !important;
137
+ border-radius: 10px !important;
138
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(99, 102, 241, 0.1) !important;
139
+ overflow: hidden;
140
+ font-family: 'Segoe UI', Tahoma, Arial, sans-serif !important;
141
+ font-size: 13px !important;
142
+ color: #f0f0f5 !important;
143
+ opacity: 0;
144
+ transform: translateY(4px);
145
+ transition: opacity 150ms ease, transform 150ms ease !important;
146
+ }
147
+
148
+ .bayan-il-tooltip--visible {
149
+ opacity: 1 !important;
150
+ transform: translateY(0) !important;
151
+ }
152
+
153
+ .bayan-il-tooltip-header {
154
+ display: flex !important;
155
+ align-items: center !important;
156
+ justify-content: space-between !important;
157
+ padding: 8px 12px !important;
158
+ background: #22222e !important;
159
+ border-bottom: 1px solid #2d2d3d !important;
160
+ }
161
+
162
+ .bayan-il-tooltip-badge {
163
+ display: inline-block !important;
164
+ padding: 2px 8px !important;
165
+ border-radius: 4px !important;
166
+ font-size: 10px !important;
167
+ font-weight: 700 !important;
168
+ }
169
+
170
+ .bayan-il-badge-spelling {
171
+ background: rgba(239, 68, 68, 0.15) !important;
172
+ color: #ef4444 !important;
173
+ }
174
+ .bayan-il-badge-grammar {
175
+ background: rgba(245, 158, 11, 0.15) !important;
176
+ color: #f59e0b !important;
177
+ }
178
+ .bayan-il-badge-punctuation {
179
+ background: rgba(59, 130, 246, 0.15) !important;
180
+ color: #3b82f6 !important;
181
+ }
182
+
183
+ .bayan-il-tooltip-close {
184
+ background: none !important;
185
+ border: none !important;
186
+ color: #6b6b80 !important;
187
+ cursor: pointer !important;
188
+ font-size: 14px !important;
189
+ padding: 2px !important;
190
+ line-height: 1 !important;
191
+ }
192
+ .bayan-il-tooltip-close:hover {
193
+ color: #f0f0f5 !important;
194
+ }
195
+
196
+ .bayan-il-tooltip-body {
197
+ display: flex !important;
198
+ align-items: center !important;
199
+ gap: 8px !important;
200
+ padding: 10px 12px !important;
201
+ font-size: 14px !important;
202
+ line-height: 1.6 !important;
203
+ }
204
+
205
+ .bayan-il-tooltip-original {
206
+ color: #ef4444 !important;
207
+ text-decoration: line-through !important;
208
+ text-decoration-color: rgba(239, 68, 68, 0.4) !important;
209
+ }
210
+
211
+ .bayan-il-tooltip-arrow {
212
+ color: #6b6b80 !important;
213
+ font-size: 12px !important;
214
+ }
215
+
216
+ .bayan-il-tooltip-correction {
217
+ color: #22c55e !important;
218
+ font-weight: 600 !important;
219
+ }
220
+
221
+ .bayan-il-tooltip-actions {
222
+ display: flex !important;
223
+ gap: 6px !important;
224
+ padding: 8px 12px !important;
225
+ border-top: 1px solid #2d2d3d !important;
226
+ background: #22222e !important;
227
+ }
228
+
229
+ .bayan-il-tooltip-apply {
230
+ flex: 1;
231
+ padding: 5px 12px !important;
232
+ border: none !important;
233
+ border-radius: 6px !important;
234
+ background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
235
+ color: white !important;
236
+ font-family: 'Segoe UI', Tahoma, sans-serif !important;
237
+ font-size: 12px !important;
238
+ font-weight: 600 !important;
239
+ cursor: pointer !important;
240
+ transition: transform 150ms ease, box-shadow 150ms ease !important;
241
+ }
242
+ .bayan-il-tooltip-apply:hover {
243
+ transform: translateY(-1px) !important;
244
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3) !important;
245
+ }
246
+
247
+ .bayan-il-tooltip-ignore {
248
+ padding: 5px 12px !important;
249
+ border: 1px solid #3a3a4d !important;
250
+ border-radius: 6px !important;
251
+ background: transparent !important;
252
+ color: #9898ad !important;
253
+ font-family: 'Segoe UI', Tahoma, sans-serif !important;
254
+ font-size: 12px !important;
255
+ cursor: pointer !important;
256
+ transition: background 150ms ease !important;
257
+ }
258
+ .bayan-il-tooltip-ignore:hover {
259
+ background: #2a2a38 !important;
260
+ color: #f0f0f5 !important;
261
+ }
262
+
263
+ /* ── Textarea overlay (transparent mirror) ── */
264
+
265
+ .bayan-il-overlay {
266
+ background: transparent !important;
267
+ pointer-events: none !important;
268
+ }
extension/content-inline.js ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan — Inline Analysis Engine (Phase 7.1 Simplified)
3
+ *
4
+ * Dependencies: BayanController (analysis-controller.js)
5
+ * State: local variables only (no external state manager)
6
+ *
7
+ * Architecture:
8
+ * 1. DETECTION — find editable fields
9
+ * 2. ANALYSIS — debounced via BayanController → background.js
10
+ * 3. OVERLAY — highlights, tooltip, FAB (never modifies user DOM content)
11
+ *
12
+ * Safety:
13
+ * - Overlay-only rendering for ALL field types (never replaces innerHTML of user content)
14
+ * - Trusted Types for Gmail/Google Sites
15
+ * - Error boundary with graceful pause
16
+ * - Protected sites: disable contenteditable on Gmail/Docs/Notion
17
+ */
18
+
19
+ (function () {
20
+ 'use strict';
21
+
22
+ if (window.location.protocol === 'chrome-extension:') return;
23
+
24
+ if (typeof BayanController === 'undefined') {
25
+ console.error('[Bayan] BayanController not loaded.');
26
+ return;
27
+ }
28
+
29
+ // ══════════════════════════════════════════════════════════
30
+ // Trusted Types
31
+ // ══════════════════════════════════════════════════════════
32
+ let ttPolicy = null;
33
+ try {
34
+ if (window.trustedTypes?.createPolicy) {
35
+ ttPolicy = window.trustedTypes.createPolicy('bayan-inline-policy', {
36
+ createHTML: (input) => input,
37
+ });
38
+ }
39
+ } catch {}
40
+
41
+ function safeHTML(el, html) {
42
+ try {
43
+ el.innerHTML = ttPolicy ? ttPolicy.createHTML(html) : html;
44
+ } catch {
45
+ el.textContent = '';
46
+ const t = document.createElement('template');
47
+ t.innerHTML = html;
48
+ el.appendChild(t.content.cloneNode(true));
49
+ }
50
+ }
51
+
52
+ // ══════════════════════════════════════════════════════════
53
+ // Local state (replaces BayanState — simpler, no extra file)
54
+ // ══════════════════════════════════════════════════════════
55
+ const IS_PROTECTED = BayanController.isProtectedSite();
56
+
57
+ let activeField = null;
58
+ let lastAnalyzedText = '';
59
+ let suggestions = [];
60
+ let paused = false;
61
+ let floatingBtn = null;
62
+ let tooltip = null;
63
+ let overlayContainer = null;
64
+ let badgeCount = null;
65
+ let observer = null;
66
+
67
+ // ══════════════════════════════════════════════════════════
68
+ // 1. DETECTION
69
+ // ══════════════════════════════════════════════════════════
70
+
71
+ function isEditableField(el) {
72
+ if (!el?.tagName) return false;
73
+ const tag = el.tagName.toLowerCase();
74
+ if (tag === 'textarea') return true;
75
+ if (tag === 'input') {
76
+ const type = (el.type || '').toLowerCase();
77
+ return ['text', 'search', 'url', ''].includes(type);
78
+ }
79
+ // Contenteditable: allow on non-protected sites
80
+ if (el.isContentEditable && !IS_PROTECTED) return true;
81
+ return false;
82
+ }
83
+
84
+ function getFieldText(field) {
85
+ if (!field) return '';
86
+ const tag = field.tagName.toLowerCase();
87
+ if (tag === 'textarea' || tag === 'input') return field.value || '';
88
+ return field.innerText || field.textContent || '';
89
+ }
90
+
91
+ // ══════════════════════════════════════════════════════════
92
+ // 2. ANALYSIS (delegates to BayanController)
93
+ // ══════════════════════════════════════════════════════════
94
+
95
+ function onFieldInput() {
96
+ if (paused || !activeField) return;
97
+
98
+ const text = getFieldText(activeField);
99
+
100
+ if (!BayanController.hasArabic(text)) {
101
+ clearHighlights();
102
+ updateBadge(0);
103
+ return;
104
+ }
105
+
106
+ updateBadge(-1);
107
+
108
+ BayanController.scheduleAnalysis(
109
+ text,
110
+ (result) => {
111
+ try { onAnalysisResult(result); } catch (err) {
112
+ console.warn('[Bayan] Callback error:', err.message);
113
+ handleFailure('callback_error');
114
+ }
115
+ },
116
+ () => getFieldText(activeField)
117
+ );
118
+ }
119
+
120
+ function onAnalysisResult(data) {
121
+ if (!activeField) return;
122
+
123
+ if (!data) {
124
+ updateBadge(0);
125
+ return;
126
+ }
127
+
128
+ suggestions = data.suggestions || [];
129
+ lastAnalyzedText = data.original || getFieldText(activeField);
130
+ updateBadge(suggestions.length);
131
+
132
+ if (suggestions.length > 0) {
133
+ renderOverlay(activeField, lastAnalyzedText, suggestions);
134
+ } else {
135
+ clearHighlights();
136
+ }
137
+ }
138
+
139
+ // ═══════════════��══════════════════════════════════════════
140
+ // Error boundary
141
+ // ══════════════════════════════════════════════════════════
142
+
143
+ function handleFailure(reason) {
144
+ paused = true;
145
+ clearHighlights();
146
+ BayanController.cancelAll();
147
+ if (badgeCount) {
148
+ badgeCount.textContent = '⏸';
149
+ badgeCount.className = 'bayan-il-badge bayan-il-badge--paused';
150
+ }
151
+ console.warn(`[Bayan] Inline paused: ${reason}. Side Panel still works.`);
152
+ }
153
+
154
+ // ══════════════════════════════════════════════════════════
155
+ // 3. OVERLAY — always overlay, never modify user content
156
+ //
157
+ // Task 10: ALL field types use overlay rendering.
158
+ // This prevents formatting destruction on Medium, Slack,
159
+ // WordPress, Ghost, Discourse, etc.
160
+ // ══════════════════════════════════════════════════════════
161
+
162
+ function renderOverlay(field, originalText, suggs) {
163
+ clearHighlights();
164
+ if (!field || !suggs?.length) return;
165
+
166
+ try {
167
+ const rect = field.getBoundingClientRect();
168
+ const cs = window.getComputedStyle(field);
169
+
170
+ overlayContainer = document.createElement('div');
171
+ overlayContainer.className = 'bayan-il-overlay';
172
+ overlayContainer.style.cssText = `position:absolute;top:${rect.top + window.scrollY}px;`
173
+ + `left:${rect.left + window.scrollX}px;width:${rect.width}px;height:${rect.height}px;`
174
+ + `font-family:${cs.fontFamily};font-size:${cs.fontSize};line-height:${cs.lineHeight};`
175
+ + `padding:${cs.padding};border:${cs.border};border-color:transparent;`
176
+ + `direction:${cs.direction};text-align:${cs.textAlign};overflow:hidden;`
177
+ + `pointer-events:none;z-index:2147483645;box-sizing:border-box;`
178
+ + `white-space:pre-wrap;word-wrap:break-word;color:transparent;`;
179
+
180
+ const sorted = [...suggs].sort((a, b) => a.start - b.start);
181
+ let html = '';
182
+ let pos = 0;
183
+
184
+ sorted.forEach((s) => {
185
+ if (pos < s.start) html += esc(originalText.slice(pos, s.start));
186
+ html += `<span class="${errCls(s.type)} bayan-il-error bayan-il-overlay-mark" `
187
+ + `data-bayan-sid="${s.id || ''}" data-bayan-original="${esc(s.original)}" `
188
+ + `data-bayan-correction="${esc(s.correction)}" data-bayan-type="${s.type}" `
189
+ + `data-bayan-start="${s.start}" data-bayan-end="${s.end}" `
190
+ + `style="pointer-events:auto;cursor:pointer;">`
191
+ + `${esc(originalText.slice(s.start, s.end))}</span>`;
192
+ pos = s.end;
193
+ });
194
+ if (pos < originalText.length) html += esc(originalText.slice(pos));
195
+
196
+ safeHTML(overlayContainer, html);
197
+ overlayContainer.scrollTop = field.scrollTop;
198
+ overlayContainer.scrollLeft = field.scrollLeft;
199
+ document.body.appendChild(overlayContainer);
200
+
201
+ overlayContainer.addEventListener('mousedown', (e) => e.preventDefault());
202
+
203
+ field.addEventListener('scroll', syncOverlay);
204
+
205
+ overlayContainer.querySelectorAll('.bayan-il-overlay-mark').forEach((mark) => {
206
+ mark.addEventListener('click', (e) => {
207
+ e.stopPropagation();
208
+ showTooltip(mark, activeField);
209
+ });
210
+ });
211
+ } catch (err) {
212
+ console.warn('[Bayan] Overlay error:', err.message);
213
+ handleFailure('render_error');
214
+ }
215
+ }
216
+
217
+ function syncOverlay() {
218
+ if (overlayContainer && activeField) {
219
+ overlayContainer.scrollTop = activeField.scrollTop;
220
+ overlayContainer.scrollLeft = activeField.scrollLeft;
221
+ }
222
+ }
223
+
224
+ function clearHighlights() {
225
+ if (overlayContainer) { overlayContainer.remove(); overlayContainer = null; }
226
+ hideTooltip();
227
+ }
228
+
229
+ // ══════════════════════════════════════════════════════════
230
+ // Tooltip
231
+ // ══════════════════════════════════════════════════════════
232
+
233
+ function showTooltip(el, field) {
234
+ hideTooltip();
235
+
236
+ const original = el.dataset.bayanOriginal;
237
+ const correction = el.dataset.bayanCorrection;
238
+ const type = el.dataset.bayanType;
239
+ const start = parseInt(el.dataset.bayanStart, 10);
240
+ const end = parseInt(el.dataset.bayanEnd, 10);
241
+ const sid = el.dataset.bayanSid;
242
+
243
+ if (!original || !correction) return;
244
+
245
+ const suggestion = { id: sid, original, correction, type, start, end };
246
+ const typeLabels = { spelling: 'إملائي', grammar: 'نحوي', punctuation: 'ترقيم' };
247
+
248
+ tooltip = document.createElement('div');
249
+ tooltip.className = 'bayan-il-tooltip';
250
+ tooltip.dir = 'rtl';
251
+
252
+ safeHTML(tooltip, `
253
+ <div class="bayan-il-tooltip-header">
254
+ <span class="bayan-il-tooltip-badge bayan-il-badge-${type}">${typeLabels[type] || type}</span>
255
+ <button class="bayan-il-tooltip-close" title="إغلاق">✕</button>
256
+ </div>
257
+ <div class="bayan-il-tooltip-body">
258
+ <span class="bayan-il-tooltip-original">${esc(original)}</span>
259
+ <span class="bayan-il-tooltip-arrow">←</span>
260
+ <span class="bayan-il-tooltip-correction">${esc(correction)}</span>
261
+ </div>
262
+ <div class="bayan-il-tooltip-actions">
263
+ <button class="bayan-il-tooltip-apply" data-action="apply">تطبيق</button>
264
+ <button class="bayan-il-tooltip-ignore" data-action="ignore">تجاهل</button>
265
+ </div>
266
+ `);
267
+
268
+ document.body.appendChild(tooltip);
269
+
270
+ const r = el.getBoundingClientRect();
271
+ tooltip.style.top = `${r.bottom + window.scrollY + 6}px`;
272
+ tooltip.style.left = `${r.left + window.scrollX}px`;
273
+
274
+ requestAnimationFrame(() => {
275
+ if (!tooltip) return;
276
+ const tr = tooltip.getBoundingClientRect();
277
+ if (tr.right > window.innerWidth) tooltip.style.left = `${window.innerWidth - tr.width - 8}px`;
278
+ tooltip.classList.add('bayan-il-tooltip--visible');
279
+ });
280
+
281
+ tooltip.querySelector('[data-action="apply"]').addEventListener('click', () => {
282
+ applyFix(field, suggestion);
283
+ hideTooltip();
284
+ });
285
+
286
+ tooltip.querySelector('[data-action="ignore"]').addEventListener('click', () => {
287
+ dismissSuggestion(suggestion);
288
+ hideTooltip();
289
+ });
290
+
291
+ tooltip.querySelector('.bayan-il-tooltip-close').addEventListener('click', () => hideTooltip());
292
+
293
+ setTimeout(() => document.addEventListener('click', outsideClick, { once: true }), 100);
294
+ }
295
+
296
+ function outsideClick(e) {
297
+ if (tooltip && !tooltip.contains(e.target)) hideTooltip();
298
+ }
299
+
300
+ function hideTooltip() {
301
+ if (tooltip) { tooltip.remove(); tooltip = null; }
302
+ }
303
+
304
+ // ══════════════════════════════════════════════════════════
305
+ // Fix application (overlay-only: modify field value, re-render overlay)
306
+ // ══════════════════════════════════════════════════════════
307
+
308
+ function applyFix(field, suggestion) {
309
+ if (!field) return;
310
+
311
+ const isInput = field.tagName.toLowerCase() === 'textarea' || field.tagName.toLowerCase() === 'input';
312
+ const text = isInput ? field.value : getFieldText(field);
313
+ const before = text.substring(0, suggestion.start);
314
+ const after = text.substring(suggestion.end);
315
+ const newText = before + suggestion.correction + after;
316
+
317
+ const delta = suggestion.correction.length - (suggestion.end - suggestion.start);
318
+ suggestions = suggestions
319
+ .filter((s) => s.id !== suggestion.id)
320
+ .map((s) => s.start >= suggestion.end ? { ...s, start: s.start + delta, end: s.end + delta } : s);
321
+
322
+ lastAnalyzedText = newText;
323
+
324
+ if (isInput) {
325
+ field.value = newText;
326
+ field.dispatchEvent(new Event('input', { bubbles: true }));
327
+ } else {
328
+ field.innerText = newText;
329
+ }
330
+
331
+ if (suggestions.length > 0) {
332
+ renderOverlay(field, newText, suggestions);
333
+ } else {
334
+ clearHighlights();
335
+ }
336
+ updateBadge(suggestions.length);
337
+ }
338
+
339
+ function dismissSuggestion(suggestion) {
340
+ suggestions = suggestions.filter((s) => s.id !== suggestion.id);
341
+ if (suggestions.length > 0) {
342
+ renderOverlay(activeField, lastAnalyzedText, suggestions);
343
+ } else {
344
+ clearHighlights();
345
+ }
346
+ updateBadge(suggestions.length);
347
+ }
348
+
349
+ // ══════════════════════════════════════════════════════════
350
+ // UI: FAB + Badge
351
+ // ══════════════════════════════════════════════════════════
352
+
353
+ function createFloatingBtn() {
354
+ if (floatingBtn) return;
355
+
356
+ floatingBtn = document.createElement('div');
357
+ floatingBtn.className = 'bayan-il-fab';
358
+ safeHTML(floatingBtn, `
359
+ <svg width="18" height="18" viewBox="0 0 100 100" fill="none">
360
+ <circle cx="50" cy="50" r="46" fill="url(#blGrad)" />
361
+ <path d="M30 55 Q35 35, 50 30 Q65 35, 70 55 Q65 65, 50 70 Q35 65, 30 55Z" fill="rgba(255,255,255,0.9)" />
362
+ <circle cx="50" cy="42" r="4" fill="url(#blGrad)" />
363
+ <defs>
364
+ <linearGradient id="blGrad" x1="0" y1="0" x2="100" y2="100">
365
+ <stop offset="0%" stop-color="#6366f1"/>
366
+ <stop offset="100%" stop-color="#8b5cf6"/>
367
+ </linearGradient>
368
+ </defs>
369
+ </svg>
370
+ <span class="bayan-il-badge">0</span>
371
+ `);
372
+ floatingBtn.title = 'Bayan — بيان';
373
+ document.body.appendChild(floatingBtn);
374
+ badgeCount = floatingBtn.querySelector('.bayan-il-badge');
375
+
376
+ floatingBtn.addEventListener('click', (e) => {
377
+ e.stopPropagation();
378
+ if (paused) {
379
+ paused = false;
380
+ updateBadge(0);
381
+ onFieldInput();
382
+ return;
383
+ }
384
+ if (suggestions.length > 0) {
385
+ try {
386
+ chrome.runtime.sendMessage({ type: 'OPEN_SIDEPANEL', text: lastAnalyzedText });
387
+ } catch {}
388
+ }
389
+ });
390
+ }
391
+
392
+ function positionFab(field) {
393
+ if (!floatingBtn || !field) return;
394
+ const rect = field.getBoundingClientRect();
395
+ floatingBtn.style.top = `${Math.max(4, rect.top + window.scrollY + 6)}px`;
396
+ floatingBtn.style.left = `${Math.max(4, rect.left + window.scrollX + 6)}px`;
397
+ floatingBtn.classList.add('bayan-il-fab--visible');
398
+ }
399
+
400
+ function updateBadge(count) {
401
+ if (!badgeCount) return;
402
+ if (count === -1) {
403
+ badgeCount.textContent = '…';
404
+ badgeCount.className = 'bayan-il-badge bayan-il-badge--analyzing';
405
+ } else if (count === 0) {
406
+ badgeCount.textContent = '✓';
407
+ badgeCount.className = 'bayan-il-badge bayan-il-badge--clean';
408
+ } else {
409
+ badgeCount.textContent = String(count);
410
+ badgeCount.className = 'bayan-il-badge bayan-il-badge--errors';
411
+ }
412
+ }
413
+
414
+ // ══════════════════════════════════════════════════════════
415
+ // Utilities
416
+ // ══════════════════════════════════════════════════════════
417
+
418
+ function esc(text) {
419
+ const m = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
420
+ return String(text).replace(/[&<>"']/g, (c) => m[c]);
421
+ }
422
+
423
+ function errCls(type) {
424
+ return { spelling: 'bayan-il-spelling', grammar: 'bayan-il-grammar', punctuation: 'bayan-il-punctuation' }[type] || 'bayan-il-spelling';
425
+ }
426
+
427
+ // ══════════════════════════════════════════════════════════
428
+ // Field lifecycle (Task 7: no listener tracking leak)
429
+ //
430
+ // Per-field listeners use direct add/remove (NOT _trackedListeners).
431
+ // Global listeners use addEventListener directly (cleaned on unload).
432
+ // ══════════════════════════════════════════════════════════
433
+
434
+ function attachField(field) {
435
+ if (activeField === field) return;
436
+ detachField();
437
+
438
+ activeField = field;
439
+ suggestions = [];
440
+ if (paused) paused = false;
441
+
442
+ createFloatingBtn();
443
+ positionFab(field);
444
+
445
+ field.addEventListener('input', onFieldInput);
446
+ field.addEventListener('keyup', onFieldInput);
447
+
448
+ if (BayanController.hasArabic(getFieldText(field))) {
449
+ onFieldInput();
450
+ }
451
+ }
452
+
453
+ function detachField() {
454
+ if (activeField) {
455
+ activeField.removeEventListener('input', onFieldInput);
456
+ activeField.removeEventListener('keyup', onFieldInput);
457
+ activeField.removeEventListener('scroll', syncOverlay);
458
+ }
459
+ clearHighlights();
460
+ BayanController.cancelAll();
461
+ activeField = null;
462
+ suggestions = [];
463
+ if (floatingBtn) floatingBtn.classList.remove('bayan-il-fab--visible');
464
+ }
465
+
466
+ // ══════════════════════════════════════════════════════════
467
+ // Global listeners
468
+ // ══════════════════════════════════════════════════════════
469
+
470
+ document.addEventListener('focusin', (e) => {
471
+ if (isEditableField(e.target)) attachField(e.target);
472
+ }, true);
473
+
474
+ document.addEventListener('focusout', () => {
475
+ setTimeout(() => {
476
+ if (!activeField) return;
477
+ if (document.activeElement === activeField) return;
478
+ const a = document.activeElement;
479
+ if (tooltip?.contains(a)) return;
480
+ if (floatingBtn?.contains(a)) return;
481
+ if (overlayContainer?.contains(a)) return;
482
+ if (document.querySelector('.bayan-il-tooltip')) return;
483
+ detachField();
484
+ }, 300);
485
+ }, true);
486
+
487
+ window.addEventListener('scroll', () => {
488
+ if (activeField && floatingBtn) positionFab(activeField);
489
+ if (overlayContainer && activeField) {
490
+ const rect = activeField.getBoundingClientRect();
491
+ overlayContainer.style.top = `${rect.top + window.scrollY}px`;
492
+ overlayContainer.style.left = `${rect.left + window.scrollX}px`;
493
+ }
494
+ }, { passive: true });
495
+
496
+ window.addEventListener('resize', () => {
497
+ if (activeField && floatingBtn) positionFab(activeField);
498
+ }, { passive: true });
499
+
500
+ // ── MutationObserver ──
501
+ observer = new MutationObserver(() => {
502
+ if (activeField && !document.body.contains(activeField)) detachField();
503
+ });
504
+ observer.observe(document.body, { childList: true, subtree: true });
505
+
506
+ // ── Log ──
507
+ const mode = IS_PROTECTED ? 'protected' : 'full';
508
+ console.log(`[Bayan] Inline engine v7.1 (mode: ${mode}, TT: ${ttPolicy ? 'yes' : 'no'})`);
509
+ })();
extension/content.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — Content Script
3
+ *
4
+ * Phase 1: Minimal stub — no DOM injection yet.
5
+ * Phase 5: Will detect textarea/contenteditable and show floating icon.
6
+ * Phase 6: Will apply suggestion patches to external page fields.
7
+ * Phase 7: Will add live analysis with debounce.
8
+ */
9
+
10
+ // Content script loaded — confirm injection
11
+ console.log('[Bayan] Content script loaded on:', window.location.hostname);
extension/manifest.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "__MSG_extName__",
4
+ "description": "__MSG_extDescription__",
5
+ "version": "2.1.0",
6
+ "minimum_chrome_version": "114",
7
+ "default_locale": "ar",
8
+
9
+ "icons": {
10
+ "16": "assets/icons/icon16.png",
11
+ "48": "assets/icons/icon48.png",
12
+ "128": "assets/icons/icon128.png"
13
+ },
14
+
15
+ "action": {
16
+ "default_popup": "popup.html",
17
+ "default_icon": {
18
+ "16": "assets/icons/icon16.png",
19
+ "48": "assets/icons/icon48.png"
20
+ },
21
+ "default_title": "__MSG_extName__"
22
+ },
23
+
24
+ "side_panel": {
25
+ "default_path": "sidepanel/sidepanel.html"
26
+ },
27
+
28
+ "background": {
29
+ "service_worker": "background.js"
30
+ },
31
+
32
+ "permissions": [
33
+ "contextMenus",
34
+ "activeTab",
35
+ "storage",
36
+ "sidePanel"
37
+ ],
38
+
39
+ "host_permissions": [
40
+ "https://bayan10-bayan-api.hf.space/*"
41
+ ],
42
+
43
+ "content_scripts": [
44
+ {
45
+ "matches": ["https://*/*", "http://*/*"],
46
+ "js": [
47
+ "shared/constants.js",
48
+ "shared/analysis-controller.js",
49
+ "content-inline.js"
50
+ ],
51
+ "css": ["content-inline.css"],
52
+ "run_at": "document_idle"
53
+ }
54
+ ]
55
+ }
extension/popup.css ADDED
@@ -0,0 +1,754 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════
2
+ Bayan Chrome Extension — Popup Styles
3
+ Arabic-first RTL | Dark mode | Premium feel
4
+ ══════════════════════════════════════════════ */
5
+
6
+ /* ── Design Tokens ── */
7
+ :root {
8
+ --bayan-primary: #6366f1;
9
+ --bayan-primary-light: #818cf8;
10
+ --bayan-primary-dark: #4f46e5;
11
+ --bayan-primary-glow: rgba(99, 102, 241, 0.25);
12
+
13
+ --bayan-bg: #0f0f14;
14
+ --bayan-surface: #1a1a24;
15
+ --bayan-surface-hover: #22222e;
16
+ --bayan-surface-active: #2a2a38;
17
+ --bayan-border: #2d2d3d;
18
+ --bayan-border-light: #3a3a4d;
19
+
20
+ --bayan-text: #f0f0f5;
21
+ --bayan-text-secondary: #9898ad;
22
+ --bayan-text-muted: #6b6b80;
23
+
24
+ --bayan-success: #22c55e;
25
+ --bayan-warning: #f59e0b;
26
+ --bayan-error: #ef4444;
27
+
28
+ --bayan-spelling: #ef4444;
29
+ --bayan-grammar: #f59e0b;
30
+ --bayan-punctuation: #3b82f6;
31
+
32
+ --bayan-radius: 10px;
33
+ --bayan-radius-sm: 6px;
34
+ --bayan-radius-lg: 14px;
35
+
36
+ --bayan-font: 'Segoe UI', 'SF Pro', Tahoma, Arial, sans-serif;
37
+ --bayan-font-arabic: 'Noto Sans Arabic', 'Segoe UI', Tahoma, sans-serif;
38
+
39
+ --bayan-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
40
+ --bayan-spring: 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
41
+ }
42
+
43
+ /* ── Reset ── */
44
+ *, *::before, *::after {
45
+ box-sizing: border-box;
46
+ margin: 0;
47
+ padding: 0;
48
+ }
49
+
50
+ body {
51
+ width: 400px;
52
+ min-height: 300px;
53
+ max-height: 600px;
54
+ overflow-y: auto;
55
+ overflow-x: hidden;
56
+ font-family: var(--bayan-font-arabic);
57
+ font-size: 14px;
58
+ line-height: 1.6;
59
+ color: var(--bayan-text);
60
+ background: var(--bayan-bg);
61
+ direction: rtl;
62
+ -webkit-font-smoothing: antialiased;
63
+ }
64
+
65
+ /* Custom scrollbar */
66
+ body::-webkit-scrollbar {
67
+ width: 4px;
68
+ }
69
+ body::-webkit-scrollbar-track {
70
+ background: transparent;
71
+ }
72
+ body::-webkit-scrollbar-thumb {
73
+ background: var(--bayan-border);
74
+ border-radius: 4px;
75
+ }
76
+
77
+ /* ══════════════════════════════════════════════
78
+ Header
79
+ ══════════════════════════════════════════════ */
80
+ .bayan-header {
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: space-between;
84
+ padding: 12px 16px;
85
+ background: linear-gradient(135deg, var(--bayan-surface) 0%, var(--bayan-bg) 100%);
86
+ border-bottom: 1px solid var(--bayan-border);
87
+ }
88
+
89
+ .bayan-header-brand {
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 8px;
93
+ }
94
+
95
+ .bayan-logo {
96
+ filter: drop-shadow(0 0 8px var(--bayan-primary-glow));
97
+ }
98
+
99
+ .bayan-header-title {
100
+ font-size: 18px;
101
+ font-weight: 700;
102
+ background: linear-gradient(135deg, var(--bayan-primary-light), var(--bayan-primary));
103
+ -webkit-background-clip: text;
104
+ -webkit-text-fill-color: transparent;
105
+ background-clip: text;
106
+ letter-spacing: -0.3px;
107
+ }
108
+
109
+ .bayan-header-status {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 6px;
113
+ font-size: 11px;
114
+ color: var(--bayan-text-muted);
115
+ }
116
+
117
+ .bayan-status-dot {
118
+ width: 7px;
119
+ height: 7px;
120
+ border-radius: 50%;
121
+ background: var(--bayan-text-muted);
122
+ transition: background var(--bayan-transition);
123
+ }
124
+ .bayan-status-dot.online {
125
+ background: var(--bayan-success);
126
+ box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
127
+ }
128
+ .bayan-status-dot.offline {
129
+ background: var(--bayan-error);
130
+ box-shadow: 0 0 6px rgba(239, 68, 68, 0.4);
131
+ }
132
+
133
+ /* ══════════════════════════════════════════════
134
+ Tabs
135
+ ══════════════════════════════════════════════ */
136
+ .bayan-tabs {
137
+ display: flex;
138
+ gap: 0;
139
+ border-bottom: 1px solid var(--bayan-border);
140
+ background: var(--bayan-surface);
141
+ }
142
+
143
+ .bayan-tab {
144
+ flex: 1;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ gap: 6px;
149
+ padding: 10px 16px;
150
+ border: none;
151
+ background: transparent;
152
+ color: var(--bayan-text-muted);
153
+ font-family: var(--bayan-font-arabic);
154
+ font-size: 13px;
155
+ font-weight: 500;
156
+ cursor: pointer;
157
+ transition: all var(--bayan-transition);
158
+ position: relative;
159
+ }
160
+
161
+ .bayan-tab::after {
162
+ content: '';
163
+ position: absolute;
164
+ bottom: 0;
165
+ left: 20%;
166
+ right: 20%;
167
+ height: 2px;
168
+ background: var(--bayan-primary);
169
+ border-radius: 2px 2px 0 0;
170
+ opacity: 0;
171
+ transform: scaleX(0);
172
+ transition: all var(--bayan-spring);
173
+ }
174
+
175
+ .bayan-tab:hover {
176
+ color: var(--bayan-text);
177
+ background: var(--bayan-surface-hover);
178
+ }
179
+
180
+ .bayan-tab.active {
181
+ color: var(--bayan-primary-light);
182
+ }
183
+ .bayan-tab.active::after {
184
+ opacity: 1;
185
+ transform: scaleX(1);
186
+ }
187
+
188
+ /* ══════════════════════════════════════════════
189
+ Panels
190
+ ═════���════════════════════════════════════════ */
191
+ .bayan-panel {
192
+ display: none;
193
+ padding: 16px;
194
+ animation: fadeSlideIn 250ms ease-out;
195
+ }
196
+ .bayan-panel.active {
197
+ display: block;
198
+ }
199
+
200
+ @keyframes fadeSlideIn {
201
+ from {
202
+ opacity: 0;
203
+ transform: translateY(6px);
204
+ }
205
+ to {
206
+ opacity: 1;
207
+ transform: translateY(0);
208
+ }
209
+ }
210
+
211
+ /* ══════════════════════════════════════════════
212
+ Input Group
213
+ ══════════════════════════════════════════════ */
214
+ .bayan-input-group {
215
+ margin-bottom: 12px;
216
+ }
217
+
218
+ .bayan-label {
219
+ display: block;
220
+ font-size: 12px;
221
+ font-weight: 600;
222
+ color: var(--bayan-text-secondary);
223
+ margin-bottom: 6px;
224
+ }
225
+
226
+ .bayan-textarea {
227
+ width: 100%;
228
+ min-height: 100px;
229
+ padding: 12px 14px;
230
+ border: 1px solid var(--bayan-border);
231
+ border-radius: var(--bayan-radius);
232
+ background: var(--bayan-surface);
233
+ color: var(--bayan-text);
234
+ font-family: var(--bayan-font-arabic);
235
+ font-size: 14px;
236
+ line-height: 1.8;
237
+ resize: vertical;
238
+ transition: border-color var(--bayan-transition), box-shadow var(--bayan-transition);
239
+ direction: rtl;
240
+ }
241
+
242
+ .bayan-textarea:focus {
243
+ outline: none;
244
+ border-color: var(--bayan-primary);
245
+ box-shadow: 0 0 0 3px var(--bayan-primary-glow);
246
+ }
247
+
248
+ .bayan-textarea::placeholder {
249
+ color: var(--bayan-text-muted);
250
+ }
251
+
252
+ .bayan-input-meta {
253
+ display: flex;
254
+ gap: 4px;
255
+ margin-top: 6px;
256
+ font-size: 11px;
257
+ color: var(--bayan-text-muted);
258
+ }
259
+
260
+ .bayan-separator {
261
+ margin: 0 4px;
262
+ opacity: 0.4;
263
+ }
264
+
265
+ /* ══════════════════════════════════════════════
266
+ Buttons
267
+ ══════════════════════════════════════════════ */
268
+ .bayan-actions {
269
+ display: flex;
270
+ gap: 8px;
271
+ margin-bottom: 14px;
272
+ }
273
+
274
+ .bayan-btn {
275
+ display: inline-flex;
276
+ align-items: center;
277
+ justify-content: center;
278
+ gap: 6px;
279
+ padding: 9px 18px;
280
+ border: none;
281
+ border-radius: var(--bayan-radius-sm);
282
+ font-family: var(--bayan-font-arabic);
283
+ font-size: 13px;
284
+ font-weight: 600;
285
+ cursor: pointer;
286
+ transition: all var(--bayan-transition);
287
+ }
288
+
289
+ .bayan-btn-primary {
290
+ flex: 1;
291
+ background: linear-gradient(135deg, var(--bayan-primary), var(--bayan-primary-dark));
292
+ color: white;
293
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
294
+ }
295
+ .bayan-btn-primary:hover {
296
+ box-shadow: 0 4px 16px rgba(99, 102, 241, 0.45);
297
+ transform: translateY(-1px);
298
+ }
299
+ .bayan-btn-primary:active {
300
+ transform: translateY(0);
301
+ box-shadow: 0 1px 4px rgba(99, 102, 241, 0.2);
302
+ }
303
+
304
+ .bayan-btn-ghost {
305
+ background: transparent;
306
+ color: var(--bayan-text-secondary);
307
+ border: 1px solid var(--bayan-border);
308
+ }
309
+ .bayan-btn-ghost:hover {
310
+ background: var(--bayan-surface-hover);
311
+ color: var(--bayan-text);
312
+ }
313
+
314
+ .bayan-btn-sm {
315
+ padding: 5px 12px;
316
+ font-size: 11px;
317
+ background: var(--bayan-primary);
318
+ color: white;
319
+ border-radius: var(--bayan-radius-sm);
320
+ }
321
+ .bayan-btn-sm:hover {
322
+ background: var(--bayan-primary-dark);
323
+ }
324
+
325
+ .bayan-btn-icon {
326
+ display: flex;
327
+ align-items: center;
328
+ justify-content: center;
329
+ width: 28px;
330
+ height: 28px;
331
+ padding: 0;
332
+ border: none;
333
+ border-radius: var(--bayan-radius-sm);
334
+ background: transparent;
335
+ color: var(--bayan-text-muted);
336
+ cursor: pointer;
337
+ transition: all var(--bayan-transition);
338
+ }
339
+ .bayan-btn-icon:hover {
340
+ background: var(--bayan-surface-hover);
341
+ color: var(--bayan-text);
342
+ }
343
+
344
+ /* ══════════════════════════════════════════════
345
+ Score Ring
346
+ ══════════════════════════════════════════════ */
347
+ .bayan-score-section {
348
+ display: flex;
349
+ align-items: center;
350
+ gap: 14px;
351
+ padding: 12px;
352
+ margin-bottom: 14px;
353
+ background: var(--bayan-surface);
354
+ border: 1px solid var(--bayan-border);
355
+ border-radius: var(--bayan-radius);
356
+ animation: fadeSlideIn 300ms ease-out;
357
+ }
358
+
359
+ .bayan-score-ring {
360
+ position: relative;
361
+ width: 80px;
362
+ height: 80px;
363
+ flex-shrink: 0;
364
+ }
365
+
366
+ .bayan-score-ring svg {
367
+ display: block;
368
+ }
369
+
370
+ .bayan-score-ring circle:last-child {
371
+ transition: stroke-dashoffset 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
372
+ }
373
+
374
+ .bayan-score-value {
375
+ position: absolute;
376
+ inset: 0;
377
+ display: flex;
378
+ align-items: center;
379
+ justify-content: center;
380
+ font-size: 20px;
381
+ font-weight: 700;
382
+ color: var(--bayan-primary-light);
383
+ }
384
+
385
+ .bayan-score-meta {
386
+ flex: 1;
387
+ }
388
+
389
+ .bayan-score-hint {
390
+ display: block;
391
+ font-size: 12px;
392
+ color: var(--bayan-text-secondary);
393
+ margin-bottom: 8px;
394
+ }
395
+
396
+ .bayan-counts {
397
+ display: flex;
398
+ gap: 10px;
399
+ flex-wrap: wrap;
400
+ }
401
+
402
+ .bayan-count {
403
+ font-size: 11px;
404
+ color: var(--bayan-text-muted);
405
+ display: flex;
406
+ align-items: center;
407
+ gap: 3px;
408
+ }
409
+ .bayan-count span {
410
+ font-weight: 700;
411
+ }
412
+ .bayan-count-spelling span { color: var(--bayan-spelling); }
413
+ .bayan-count-grammar span { color: var(--bayan-grammar); }
414
+ .bayan-count-punctuation span { color: var(--bayan-punctuation); }
415
+
416
+ /* ══════════════════════════════════════════════
417
+ Result
418
+ ══════════════════════════════════════════════ */
419
+ .bayan-result {
420
+ margin-bottom: 14px;
421
+ animation: fadeSlideIn 300ms ease-out;
422
+ }
423
+
424
+ .bayan-result-header {
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: space-between;
428
+ margin-bottom: 8px;
429
+ }
430
+
431
+ .bayan-result-title {
432
+ font-size: 12px;
433
+ font-weight: 600;
434
+ color: var(--bayan-text-secondary);
435
+ }
436
+
437
+ .bayan-result-text {
438
+ padding: 12px 14px;
439
+ background: var(--bayan-surface);
440
+ border: 1px solid var(--bayan-border);
441
+ border-radius: var(--bayan-radius);
442
+ font-size: 14px;
443
+ line-height: 2;
444
+ color: var(--bayan-text);
445
+ min-height: 60px;
446
+ word-wrap: break-word;
447
+ }
448
+
449
+ /* Error highlights in result */
450
+ .bayan-spelling-error {
451
+ background: rgba(239, 68, 68, 0.15);
452
+ border-bottom: 2px solid var(--bayan-spelling);
453
+ border-radius: 2px;
454
+ padding: 0 2px;
455
+ cursor: pointer;
456
+ transition: background var(--bayan-transition);
457
+ }
458
+ .bayan-spelling-error:hover {
459
+ background: rgba(239, 68, 68, 0.25);
460
+ }
461
+
462
+ .bayan-grammar-error {
463
+ background: rgba(245, 158, 11, 0.15);
464
+ border-bottom: 2px solid var(--bayan-grammar);
465
+ border-radius: 2px;
466
+ padding: 0 2px;
467
+ cursor: pointer;
468
+ transition: background var(--bayan-transition);
469
+ }
470
+ .bayan-grammar-error:hover {
471
+ background: rgba(245, 158, 11, 0.25);
472
+ }
473
+
474
+ .bayan-punctuation-suggestion {
475
+ background: rgba(59, 130, 246, 0.15);
476
+ border-bottom: 2px solid var(--bayan-punctuation);
477
+ border-radius: 2px;
478
+ padding: 0 2px;
479
+ cursor: pointer;
480
+ transition: background var(--bayan-transition);
481
+ }
482
+ .bayan-punctuation-suggestion:hover {
483
+ background: rgba(59, 130, 246, 0.25);
484
+ }
485
+
486
+ /* ══════════════════════════════════════════════
487
+ Suggestions List
488
+ ══════════════════════════════════════════════ */
489
+ .bayan-suggestions {
490
+ animation: fadeSlideIn 300ms ease-out;
491
+ }
492
+
493
+ .bayan-suggestions-header {
494
+ display: flex;
495
+ align-items: center;
496
+ justify-content: space-between;
497
+ margin-bottom: 10px;
498
+ }
499
+
500
+ .bayan-suggestions-title {
501
+ font-size: 12px;
502
+ font-weight: 600;
503
+ color: var(--bayan-text-secondary);
504
+ }
505
+
506
+ .bayan-suggestions-list {
507
+ display: flex;
508
+ flex-direction: column;
509
+ gap: 8px;
510
+ }
511
+
512
+ .bayan-suggestion-card {
513
+ padding: 10px 12px;
514
+ background: var(--bayan-surface);
515
+ border: 1px solid var(--bayan-border);
516
+ border-radius: var(--bayan-radius);
517
+ cursor: pointer;
518
+ transition: all var(--bayan-transition);
519
+ }
520
+ .bayan-suggestion-card:hover {
521
+ border-color: var(--bayan-primary);
522
+ background: var(--bayan-surface-hover);
523
+ box-shadow: 0 0 0 1px var(--bayan-primary-glow);
524
+ }
525
+
526
+ .bayan-suggestion-badge {
527
+ display: inline-block;
528
+ padding: 2px 8px;
529
+ border-radius: 20px;
530
+ font-size: 10px;
531
+ font-weight: 700;
532
+ margin-bottom: 6px;
533
+ }
534
+
535
+ .bayan-badge-spelling {
536
+ background: rgba(239, 68, 68, 0.15);
537
+ color: var(--bayan-spelling);
538
+ }
539
+ .bayan-badge-grammar {
540
+ background: rgba(245, 158, 11, 0.15);
541
+ color: var(--bayan-grammar);
542
+ }
543
+ .bayan-badge-punctuation {
544
+ background: rgba(59, 130, 246, 0.15);
545
+ color: var(--bayan-punctuation);
546
+ }
547
+
548
+ .bayan-suggestion-change {
549
+ display: flex;
550
+ align-items: center;
551
+ gap: 6px;
552
+ margin-bottom: 8px;
553
+ font-size: 13px;
554
+ }
555
+
556
+ .bayan-suggestion-original {
557
+ text-decoration: line-through;
558
+ opacity: 0.55;
559
+ color: var(--bayan-error);
560
+ }
561
+
562
+ .bayan-suggestion-arrow {
563
+ color: var(--bayan-text-muted);
564
+ font-size: 12px;
565
+ }
566
+
567
+ .bayan-suggestion-correction {
568
+ font-weight: 600;
569
+ color: var(--bayan-success);
570
+ }
571
+
572
+ .bayan-suggestion-alts {
573
+ display: flex;
574
+ flex-wrap: wrap;
575
+ gap: 6px;
576
+ }
577
+
578
+ .bayan-alt-chip {
579
+ padding: 4px 10px;
580
+ border: 1px solid var(--bayan-border);
581
+ border-radius: 20px;
582
+ background: transparent;
583
+ color: var(--bayan-text-secondary);
584
+ font-family: var(--bayan-font-arabic);
585
+ font-size: 11px;
586
+ cursor: pointer;
587
+ transition: all var(--bayan-transition);
588
+ }
589
+ .bayan-alt-chip:hover {
590
+ border-color: var(--bayan-primary);
591
+ color: var(--bayan-text);
592
+ background: var(--bayan-surface-active);
593
+ }
594
+
595
+ .bayan-alt-chip--main {
596
+ background: var(--bayan-primary);
597
+ border-color: var(--bayan-primary);
598
+ color: white;
599
+ }
600
+ .bayan-alt-chip--main:hover {
601
+ background: var(--bayan-primary-dark);
602
+ color: white;
603
+ }
604
+
605
+ .bayan-alt-chip--keep {
606
+ opacity: 0.65;
607
+ }
608
+ .bayan-alt-chip--keep:hover {
609
+ opacity: 1;
610
+ }
611
+
612
+ /* ══��═══════════════════════════════════════════
613
+ Timing
614
+ ══════════════════════════════════════════════ */
615
+ .bayan-timing {
616
+ padding: 8px 0;
617
+ margin-top: 8px;
618
+ border-top: 1px solid var(--bayan-border);
619
+ font-size: 10px;
620
+ color: var(--bayan-text-muted);
621
+ text-align: center;
622
+ }
623
+
624
+ /* ══════════════════════════════════════════════
625
+ Summary Meta
626
+ ══════════════════════════════════════════════ */
627
+ .bayan-summary-meta {
628
+ margin-top: 10px;
629
+ padding: 8px 12px;
630
+ background: var(--bayan-surface-hover);
631
+ border-radius: var(--bayan-radius-sm);
632
+ font-size: 11px;
633
+ color: var(--bayan-text-muted);
634
+ }
635
+
636
+ /* ══════════════════════════════════════════════
637
+ Length Selector
638
+ ══════════════════════════════════════════════ */
639
+ .bayan-length-selector {
640
+ margin-bottom: 12px;
641
+ }
642
+
643
+ .bayan-radio-group {
644
+ display: flex;
645
+ gap: 12px;
646
+ }
647
+
648
+ .bayan-radio {
649
+ display: flex;
650
+ align-items: center;
651
+ gap: 4px;
652
+ font-size: 12px;
653
+ color: var(--bayan-text-secondary);
654
+ cursor: pointer;
655
+ }
656
+
657
+ .bayan-radio input[type="radio"] {
658
+ accent-color: var(--bayan-primary);
659
+ width: 14px;
660
+ height: 14px;
661
+ cursor: pointer;
662
+ }
663
+
664
+ /* ══════════════════════════════════════════════
665
+ Loading Overlay
666
+ ══════════════════════════════════════════════ */
667
+ .bayan-loading {
668
+ position: fixed;
669
+ inset: 0;
670
+ display: flex;
671
+ flex-direction: column;
672
+ align-items: center;
673
+ justify-content: center;
674
+ gap: 14px;
675
+ background: rgba(15, 15, 20, 0.85);
676
+ backdrop-filter: blur(4px);
677
+ z-index: 100;
678
+ }
679
+
680
+ .bayan-spinner {
681
+ width: 36px;
682
+ height: 36px;
683
+ border: 3px solid var(--bayan-border);
684
+ border-top-color: var(--bayan-primary);
685
+ border-radius: 50%;
686
+ animation: spin 0.8s linear infinite;
687
+ }
688
+
689
+ @keyframes spin {
690
+ to { transform: rotate(360deg); }
691
+ }
692
+
693
+ .bayan-loading-text {
694
+ font-size: 13px;
695
+ color: var(--bayan-text-secondary);
696
+ }
697
+
698
+ /* ══════════════════════════════════════════════
699
+ Toast
700
+ ══════════════════════════════════════════════ */
701
+ .bayan-toast {
702
+ position: fixed;
703
+ bottom: -50px;
704
+ left: 50%;
705
+ transform: translateX(-50%);
706
+ padding: 8px 18px;
707
+ background: var(--bayan-surface);
708
+ border: 1px solid var(--bayan-border);
709
+ border-radius: 20px;
710
+ color: var(--bayan-text);
711
+ font-size: 12px;
712
+ font-family: var(--bayan-font-arabic);
713
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
714
+ transition: bottom var(--bayan-spring);
715
+ z-index: 200;
716
+ white-space: nowrap;
717
+ }
718
+ .bayan-toast.is-visible {
719
+ bottom: 16px;
720
+ }
721
+
722
+ /* ══════════════════════════════════════════════
723
+ MED-2: Stale analysis indicator
724
+ ══════════════════════════════════════════════ */
725
+ .bayan-stale {
726
+ opacity: 0.45;
727
+ pointer-events: none;
728
+ position: relative;
729
+ filter: grayscale(30%);
730
+ transition: opacity var(--bayan-transition), filter var(--bayan-transition);
731
+ }
732
+ .bayan-stale::after {
733
+ content: '⚠ أعد التحليل';
734
+ position: absolute;
735
+ top: 8px;
736
+ left: 50%;
737
+ transform: translateX(-50%);
738
+ padding: 4px 12px;
739
+ background: var(--bayan-warning);
740
+ color: #000;
741
+ font-size: 10px;
742
+ font-weight: 700;
743
+ border-radius: 12px;
744
+ z-index: 10;
745
+ pointer-events: none;
746
+ white-space: nowrap;
747
+ }
748
+
749
+ /* ══════════════════════════════════════════════
750
+ Utilities
751
+ ══════════════════════════════════════════════ */
752
+ .is-hidden {
753
+ display: none !important;
754
+ }
extension/popup.html ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=400">
6
+ <title>بيان</title>
7
+ <link rel="stylesheet" href="popup.css">
8
+ </head>
9
+ <body>
10
+ <!-- ══════════════════════════════════════════════ -->
11
+ <!-- Header -->
12
+ <!-- ══════════════════════════════════════════════ -->
13
+ <header class="bayan-header">
14
+ <div class="bayan-header-brand">
15
+ <svg class="bayan-logo" width="28" height="28" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
16
+ <circle cx="50" cy="50" r="46" fill="url(#logoGrad)" />
17
+ <path d="M30 55 Q35 35, 50 30 Q65 35, 70 55 Q65 65, 50 70 Q35 65, 30 55Z" fill="rgba(255,255,255,0.9)" />
18
+ <circle cx="50" cy="42" r="4" fill="url(#logoGrad)" />
19
+ <defs>
20
+ <linearGradient id="logoGrad" x1="0" y1="0" x2="100" y2="100">
21
+ <stop offset="0%" stop-color="#6366f1"/>
22
+ <stop offset="100%" stop-color="#8b5cf6"/>
23
+ </linearGradient>
24
+ </defs>
25
+ </svg>
26
+ <span class="bayan-header-title">بيان</span>
27
+ </div>
28
+ <div class="bayan-header-status" id="status-indicator">
29
+ <span class="bayan-status-dot"></span>
30
+ <span class="bayan-status-text" id="status-text">جاهز</span>
31
+ </div>
32
+ </header>
33
+
34
+ <!-- ══════════════════════════════════════════════ -->
35
+ <!-- Tab Navigation -->
36
+ <!-- ══════════════════════════════════════════════ -->
37
+ <nav class="bayan-tabs" role="tablist">
38
+ <button class="bayan-tab active" role="tab" aria-selected="true" data-tab="correct" id="tab-correct">
39
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
40
+ تصحيح
41
+ </button>
42
+ <button class="bayan-tab" role="tab" aria-selected="false" data-tab="summarize" id="tab-summarize">
43
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h10M4 18h14"/></svg>
44
+ تلخيص
45
+ </button>
46
+ </nav>
47
+
48
+ <!-- ══════════════════════════════════════════════ -->
49
+ <!-- Correct Panel -->
50
+ <!-- ══════════════════════════════════════════════ -->
51
+ <section class="bayan-panel active" id="panel-correct" role="tabpanel">
52
+ <!-- Input area -->
53
+ <div class="bayan-input-group">
54
+ <label class="bayan-label" for="input-text">أدخل النص العربي</label>
55
+ <textarea
56
+ id="input-text"
57
+ class="bayan-textarea"
58
+ dir="rtl"
59
+ placeholder="الصق أو اكتب النص هنا..."
60
+ rows="5"
61
+ maxlength="5000"
62
+ ></textarea>
63
+ <div class="bayan-input-meta">
64
+ <span id="char-count">٠</span> حرف
65
+ <span class="bayan-separator">|</span>
66
+ <span id="word-count">٠</span> كلمة
67
+ </div>
68
+ </div>
69
+
70
+ <!-- Action buttons -->
71
+ <div class="bayan-actions">
72
+ <button class="bayan-btn bayan-btn-primary" id="btn-correct" type="button">
73
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"/></svg>
74
+ تحليل وتصحيح
75
+ </button>
76
+ <button class="bayan-btn bayan-btn-ghost" id="btn-clear" type="button">مسح</button>
77
+ </div>
78
+
79
+ <!-- Score ring -->
80
+ <div class="bayan-score-section is-hidden" id="score-section">
81
+ <div class="bayan-score-ring">
82
+ <svg viewBox="0 0 160 160" width="80" height="80">
83
+ <circle cx="80" cy="80" r="70" fill="none" stroke="var(--bayan-border)" stroke-width="8"/>
84
+ <circle cx="80" cy="80" r="70" fill="none" stroke="var(--bayan-primary)" stroke-width="8"
85
+ stroke-dasharray="440" stroke-dashoffset="440" stroke-linecap="round"
86
+ transform="rotate(-90 80 80)" id="score-circle"/>
87
+ </svg>
88
+ <span class="bayan-score-value" id="score-value">--</span>
89
+ </div>
90
+ <div class="bayan-score-meta">
91
+ <span class="bayan-score-hint" id="score-hint">ابدأ الكتابة لرؤية تقييمك</span>
92
+ <div class="bayan-counts" id="error-counts">
93
+ <span class="bayan-count bayan-count-spelling"><span id="count-spelling">٠</span> إملائي</span>
94
+ <span class="bayan-count bayan-count-grammar"><span id="count-grammar">٠</span> نحوي</span>
95
+ <span class="bayan-count bayan-count-punctuation"><span id="count-punctuation">٠</span> ترقيم</span>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Corrected text result -->
101
+ <div class="bayan-result is-hidden" id="result-section">
102
+ <div class="bayan-result-header">
103
+ <span class="bayan-result-title">النص المصحّح</span>
104
+ <button class="bayan-btn-icon" id="btn-copy-result" type="button" title="نسخ">
105
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
106
+ </button>
107
+ </div>
108
+ <div class="bayan-result-text" id="result-text" dir="rtl"></div>
109
+ </div>
110
+
111
+ <!-- Suggestions list -->
112
+ <div class="bayan-suggestions is-hidden" id="suggestions-section">
113
+ <div class="bayan-suggestions-header">
114
+ <span class="bayan-suggestions-title">الاقتراحات</span>
115
+ <button class="bayan-btn bayan-btn-sm" id="btn-apply-all" type="button">تطبيق الكل</button>
116
+ </div>
117
+ <div class="bayan-suggestions-list" id="suggestions-list" role="list"></div>
118
+ </div>
119
+
120
+ <!-- Timing info -->
121
+ <div class="bayan-timing is-hidden" id="timing-section">
122
+ <span id="timing-text"></span>
123
+ </div>
124
+ </section>
125
+
126
+ <!-- ══════════════════════════════════════════════ -->
127
+ <!-- Summarize Panel -->
128
+ <!-- ══════════════════════════════════════════════ -->
129
+ <section class="bayan-panel" id="panel-summarize" role="tabpanel">
130
+ <!-- Input area -->
131
+ <div class="bayan-input-group">
132
+ <label class="bayan-label" for="summary-input-text">أدخل النص للتلخيص</label>
133
+ <textarea
134
+ id="summary-input-text"
135
+ class="bayan-textarea"
136
+ dir="rtl"
137
+ placeholder="الصق النص المراد تلخيصه..."
138
+ rows="5"
139
+ maxlength="5000"
140
+ ></textarea>
141
+ <div class="bayan-input-meta">
142
+ <span id="summary-char-count">٠</span> حرف
143
+ </div>
144
+ </div>
145
+
146
+ <!-- Length selector -->
147
+ <div class="bayan-length-selector">
148
+ <label class="bayan-label">طول الملخص</label>
149
+ <div class="bayan-radio-group">
150
+ <label class="bayan-radio"><input type="radio" name="summary-length" value="1"> قصير</label>
151
+ <label class="bayan-radio"><input type="radio" name="summary-length" value="2" checked> متوسط</label>
152
+ <label class="bayan-radio"><input type="radio" name="summary-length" value="3"> طويل</label>
153
+ </div>
154
+ </div>
155
+
156
+ <!-- Action -->
157
+ <div class="bayan-actions">
158
+ <button class="bayan-btn bayan-btn-primary" id="btn-summarize" type="button">
159
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h10M4 18h14"/></svg>
160
+ تلخيص
161
+ </button>
162
+ </div>
163
+
164
+ <!-- Summary result -->
165
+ <div class="bayan-result is-hidden" id="summary-result-section">
166
+ <div class="bayan-result-header">
167
+ <span class="bayan-result-title">الملخص</span>
168
+ <button class="bayan-btn-icon" id="btn-copy-summary" type="button" title="نسخ">
169
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
170
+ </button>
171
+ </div>
172
+ <div class="bayan-result-text" id="summary-text" dir="rtl"></div>
173
+ <div class="bayan-summary-meta" id="summary-meta"></div>
174
+ </div>
175
+ </section>
176
+
177
+ <!-- ══════════════════════════════════════════════ -->
178
+ <!-- Loading overlay -->
179
+ <!-- ══════════════════════════════════════════════ -->
180
+ <div class="bayan-loading is-hidden" id="loading-overlay">
181
+ <div class="bayan-spinner"></div>
182
+ <span class="bayan-loading-text" id="loading-text">جارٍ التحليل...</span>
183
+ </div>
184
+
185
+ <!-- ══════════════════════════════════════════════ -->
186
+ <!-- Toast -->
187
+ <!-- ══════════════════════════════════════════════ -->
188
+ <div class="bayan-toast" id="toast" role="status" aria-live="polite"></div>
189
+
190
+ <!-- Scripts: constants first, then config, then shared modules, then popup logic -->
191
+ <script src="shared/constants.js"></script>
192
+ <script src="shared/config.js"></script>
193
+ <script src="shared/bayan-renderer.js"></script>
194
+ <script src="shared/bayan-ui.js"></script>
195
+ <script src="shared/bayan-patches.js"></script>
196
+ <script src="shared/bayan-api.js"></script>
197
+ <script src="popup.js"></script>
198
+ </body>
199
+ </html>
extension/popup.js ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — Popup Logic
3
+ *
4
+ * Fixes applied:
5
+ * HIGH-1: Offset rebasing via applyAndRebase() after each individual apply
6
+ * MED-1: Clipboard .catch() on all writeText() calls
7
+ * MED-2: Staleness detection — locks corrections when user edits after analysis
8
+ */
9
+
10
+ document.addEventListener('DOMContentLoaded', () => {
11
+ // ── Element references ──
12
+ const inputText = document.getElementById('input-text');
13
+ const charCount = document.getElementById('char-count');
14
+ const wordCount = document.getElementById('word-count');
15
+ const btnCorrect = document.getElementById('btn-correct');
16
+ const btnClear = document.getElementById('btn-clear');
17
+ const btnApplyAll = document.getElementById('btn-apply-all');
18
+ const btnCopyResult = document.getElementById('btn-copy-result');
19
+ const scoreSection = document.getElementById('score-section');
20
+ const resultSection = document.getElementById('result-section');
21
+ const resultText = document.getElementById('result-text');
22
+ const suggestionsSection = document.getElementById('suggestions-section');
23
+ const suggestionsList = document.getElementById('suggestions-list');
24
+ const timingSection = document.getElementById('timing-section');
25
+ const timingText = document.getElementById('timing-text');
26
+ const loadingOverlay = document.getElementById('loading-overlay');
27
+ const loadingTextEl = document.getElementById('loading-text');
28
+
29
+ // Summary tab elements
30
+ const summaryInputText = document.getElementById('summary-input-text');
31
+ const summaryCharCount = document.getElementById('summary-char-count');
32
+ const btnSummarize = document.getElementById('btn-summarize');
33
+ const summaryResultSection = document.getElementById('summary-result-section');
34
+ const summaryText = document.getElementById('summary-text');
35
+ const summaryMeta = document.getElementById('summary-meta');
36
+ const btnCopySummary = document.getElementById('btn-copy-summary');
37
+
38
+ // Score elements
39
+ const scoreValue = document.getElementById('score-value');
40
+ const scoreCircle = document.getElementById('score-circle');
41
+ const scoreHint = document.getElementById('score-hint');
42
+ const countSpelling = document.getElementById('count-spelling');
43
+ const countGrammar = document.getElementById('count-grammar');
44
+ const countPunctuation = document.getElementById('count-punctuation');
45
+
46
+ // ══════════════════════════════════════════════════════════
47
+ // State
48
+ // ══════════════════════════════════════════════════════════
49
+ let currentSuggestions = [];
50
+
51
+ /**
52
+ * MED-2: Snapshot of the text that was last analyzed.
53
+ * All suggestion offsets are relative to THIS text.
54
+ * applyAndRebase() mutates this alongside suggestions.
55
+ */
56
+ let analyzedText = '';
57
+
58
+ /**
59
+ * MED-2: Whether the analysis results are stale.
60
+ * Set to true when the user edits the textarea after analysis.
61
+ * When stale, suggestion actions are blocked.
62
+ */
63
+ let isStale = false;
64
+
65
+ const SCORE_CIRCUMFERENCE = 440;
66
+
67
+ // ══════════════════════════════════════════════════════════
68
+ // Tab switching
69
+ // ══════════════════════════════════════════════════════════
70
+ document.querySelectorAll('.bayan-tab').forEach((tab) => {
71
+ tab.addEventListener('click', () => {
72
+ const targetTab = tab.dataset.tab;
73
+ document.querySelectorAll('.bayan-tab').forEach((t) => {
74
+ t.classList.toggle('active', t.dataset.tab === targetTab);
75
+ t.setAttribute('aria-selected', t.dataset.tab === targetTab ? 'true' : 'false');
76
+ });
77
+ document.querySelectorAll('.bayan-panel').forEach((p) => {
78
+ p.classList.toggle('active', p.id === `panel-${targetTab}`);
79
+ });
80
+ });
81
+ });
82
+
83
+ // ══════════════════════════════════════════════════════════
84
+ // Character & word counter
85
+ // ══════════════════════════════════════════════════════════
86
+ function updateCounts(textarea, charEl, wordEl) {
87
+ const text = textarea.value;
88
+ const chars = text.length;
89
+ const words = text.trim() ? text.trim().split(/\s+/).length : 0;
90
+ if (charEl) charEl.textContent = chars.toLocaleString('ar-EG');
91
+ if (wordEl) wordEl.textContent = words.toLocaleString('ar-EG');
92
+ }
93
+
94
+ inputText.addEventListener('input', () => {
95
+ updateCounts(inputText, charCount, wordCount);
96
+
97
+ // MED-2: Detect user edit after analysis → mark stale
98
+ if (currentSuggestions.length > 0 && inputText.value !== analyzedText) {
99
+ markStale();
100
+ }
101
+ });
102
+
103
+ summaryInputText.addEventListener('input', () => updateCounts(summaryInputText, summaryCharCount, null));
104
+
105
+ // ══════════════════════════════════════════════════════════
106
+ // MED-2: Staleness management
107
+ // ══════════════════════════════════════════════════════════
108
+
109
+ /**
110
+ * Mark analysis results as stale (user edited textarea).
111
+ * Disables suggestion actions and shows a re-analysis prompt.
112
+ */
113
+ function markStale() {
114
+ if (isStale) return; // already stale
115
+ isStale = true;
116
+
117
+ // Visual indicator: dim the results area
118
+ if (resultSection) resultSection.classList.add('bayan-stale');
119
+ if (suggestionsSection) suggestionsSection.classList.add('bayan-stale');
120
+
121
+ // Show re-analysis toast
122
+ showToast('⚠ النص تغيّر — أعد التحليل لتحديث الاقتراحات', 4000);
123
+
124
+ // Update button text to indicate re-analysis needed
125
+ btnCorrect.innerHTML = `
126
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3"/></svg>
127
+ إعادة التحليل`;
128
+ }
129
+
130
+ /**
131
+ * Clear staleness (after re-analysis or clear).
132
+ */
133
+ function clearStale() {
134
+ isStale = false;
135
+ if (resultSection) resultSection.classList.remove('bayan-stale');
136
+ if (suggestionsSection) suggestionsSection.classList.remove('bayan-stale');
137
+
138
+ // Restore button text
139
+ btnCorrect.innerHTML = `
140
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"/></svg>
141
+ تحليل وتصحيح`;
142
+ }
143
+
144
+ // ══════════════════════════════════════════════════════════
145
+ // Loading state
146
+ // ══════════════════════════════════════════════════════════
147
+ function setLoading(show, text = 'جارٍ التحليل...') {
148
+ loadingOverlay.classList.toggle('is-hidden', !show);
149
+ loadingTextEl.textContent = text;
150
+ }
151
+
152
+ // ══════════════════════════════════════════════════════════
153
+ // Toast
154
+ // ══════════════════════════════════════════════════════════
155
+ function showToast(message, duration = 2500) {
156
+ const toast = document.getElementById('toast');
157
+ toast.textContent = message;
158
+ toast.classList.add('is-visible');
159
+ clearTimeout(toast._timer);
160
+ toast._timer = setTimeout(() => toast.classList.remove('is-visible'), duration);
161
+ }
162
+
163
+ // ══════════════════════════════════════════════════════════
164
+ // Score ring
165
+ // ══════════════════════════════════════════════════════════
166
+ function updateScore(spelling, grammar, punctuation) {
167
+ const score = calculateWritingScore(spelling, grammar, punctuation);
168
+ const total = spelling + grammar + punctuation;
169
+
170
+ scoreSection.classList.remove('is-hidden');
171
+
172
+ if (scoreValue) {
173
+ scoreValue.textContent = score > 0 || total > 0 ? score.toLocaleString('ar-EG') : '--';
174
+ }
175
+ if (scoreCircle) {
176
+ const offset = SCORE_CIRCUMFERENCE - (score / 100) * SCORE_CIRCUMFERENCE;
177
+ scoreCircle.style.strokeDashoffset = String(offset);
178
+ }
179
+ if (scoreHint) {
180
+ scoreHint.textContent = getScoreHint(score, total);
181
+ }
182
+ if (countSpelling) countSpelling.textContent = spelling.toLocaleString('ar-EG');
183
+ if (countGrammar) countGrammar.textContent = grammar.toLocaleString('ar-EG');
184
+ if (countPunctuation) countPunctuation.textContent = punctuation.toLocaleString('ar-EG');
185
+ }
186
+
187
+ // ══════════════════════════════════════════════════════════
188
+ // Render suggestions list
189
+ // ══════════════════════════════════════════════════════════
190
+ function renderSuggestions(suggestions) {
191
+ currentSuggestions = suggestions;
192
+
193
+ if (!suggestions || suggestions.length === 0) {
194
+ suggestionsSection.classList.add('is-hidden');
195
+ return;
196
+ }
197
+
198
+ suggestionsSection.classList.remove('is-hidden');
199
+ const html = suggestions.map((s, i) => buildSuggestionCardHTML(s, i)).join('');
200
+ suggestionsList.innerHTML = html;
201
+
202
+ // Bind suggestion card events
203
+ suggestionsList.querySelectorAll('.bayan-alt-chip').forEach((chip) => {
204
+ chip.addEventListener('click', (e) => {
205
+ e.stopPropagation();
206
+
207
+ // MED-2: Block if stale
208
+ if (isStale) {
209
+ showToast('⚠ أعد التحليل أولاً — النص تغيّر');
210
+ return;
211
+ }
212
+
213
+ const suggestionId = chip.dataset.cardId;
214
+ const altText = chip.dataset.cardAlt;
215
+ const suggestion = currentSuggestions.find((s) => String(s.id || '') === String(suggestionId));
216
+ if (!suggestion) return;
217
+
218
+ if (altText === suggestion.original) {
219
+ // Dismiss — remove from list, no text change, no rebase needed
220
+ currentSuggestions = removeSuggestion(currentSuggestions, suggestion.id);
221
+ } else {
222
+ // ═══════════════════════════════════════════════════
223
+ // HIGH-1 FIX: Apply + Rebase via atomic function
224
+ // ═══════════════════════════════════════════════════
225
+ const result = applyAndRebase(analyzedText, suggestion, altText, currentSuggestions);
226
+ analyzedText = result.text;
227
+ currentSuggestions = result.suggestions;
228
+
229
+ // Sync textarea with the rebased text
230
+ inputText.value = analyzedText;
231
+ updateCounts(inputText, charCount, wordCount);
232
+ }
233
+
234
+ // Re-render score and suggestions
235
+ const counts = countByType(currentSuggestions);
236
+ updateScore(counts.spelling, counts.grammar, counts.punctuation);
237
+ renderSuggestions(currentSuggestions);
238
+
239
+ // Re-render highlighted text with updated offsets
240
+ resultText.innerHTML = renderHighlightedText(analyzedText, currentSuggestions);
241
+
242
+ showToast('✓ تم التصحيح');
243
+ });
244
+ });
245
+
246
+ // Show apply-all only when >= 2
247
+ btnApplyAll.classList.toggle('is-hidden', suggestions.length < 2);
248
+ }
249
+
250
+ // ══════════════════════════════════════════════════════════
251
+ // Clear button
252
+ // ══════════════════════════════════════════════════════════
253
+ btnClear.addEventListener('click', () => {
254
+ inputText.value = '';
255
+ analyzedText = '';
256
+ updateCounts(inputText, charCount, wordCount);
257
+ scoreSection.classList.add('is-hidden');
258
+ resultSection.classList.add('is-hidden');
259
+ suggestionsSection.classList.add('is-hidden');
260
+ timingSection.classList.add('is-hidden');
261
+ currentSuggestions = [];
262
+ clearStale();
263
+ });
264
+
265
+ // ══════════════════════════════════════════════════════════
266
+ // Correct button
267
+ // ══════════════════════════════════════════════════════════
268
+ btnCorrect.addEventListener('click', async () => {
269
+ const text = inputText.value.trim();
270
+ if (!text) {
271
+ showToast('أدخل نصاً للتحليل');
272
+ return;
273
+ }
274
+ if (text.length < CONFIG.MIN_ANALYZE_LENGTH) {
275
+ showToast('النص قصير جداً (الحد الأدنى ١٥ حرفاً)');
276
+ return;
277
+ }
278
+ if (text.length > CONFIG.MAX_ANALYZE_LENGTH) {
279
+ showToast('النص طويل جداً (الحد الأقصى ٥٠٠٠ حرف)');
280
+ return;
281
+ }
282
+
283
+ setLoading(true, 'جارٍ التحليل...');
284
+ clearStale(); // MED-2: Clear stale on re-analysis
285
+
286
+ try {
287
+ const data = await bayanAnalyze(text);
288
+
289
+ if (data.status === 'success' || data.status === 'partial') {
290
+ const suggestions = sortSuggestions(data.suggestions || []);
291
+ currentSuggestions = suggestions;
292
+
293
+ // MED-2: Snapshot the analyzed text — all offsets reference THIS string
294
+ analyzedText = data.original;
295
+ // Sync textarea to the exact text the backend analyzed
296
+ inputText.value = analyzedText;
297
+ updateCounts(inputText, charCount, wordCount);
298
+
299
+ // Show corrected text with highlights
300
+ resultSection.classList.remove('is-hidden');
301
+ resultText.innerHTML = renderHighlightedText(analyzedText, suggestions);
302
+
303
+ // Update score
304
+ const counts = countByType(suggestions);
305
+ updateScore(counts.spelling, counts.grammar, counts.punctuation);
306
+
307
+ // Render suggestion cards
308
+ renderSuggestions(suggestions);
309
+
310
+ // Show timing
311
+ if (data.timing_ms) {
312
+ timingSection.classList.remove('is-hidden');
313
+ timingText.textContent = `التحليل: ${data.timing_ms.total_ms || 0}ms (إملائي: ${data.timing_ms.spelling_ms || 0}ms، نحوي: ${data.timing_ms.grammar_ms || 0}ms، ترقيم: ${data.timing_ms.punctuation_ms || 0}ms)`;
314
+ }
315
+
316
+ if (suggestions.length === 0) {
317
+ showToast('نصك ممتاز! لم نجد أي أخطاء ✨');
318
+ }
319
+ } else {
320
+ showToast('تعذّر التحليل — حاول مرة أخرى');
321
+ }
322
+ } catch (error) {
323
+ console.error('[Bayan] Analysis error:', error);
324
+ showToast('خطأ في الاتصال — تحقق من الإنترنت');
325
+ } finally {
326
+ setLoading(false);
327
+ }
328
+ });
329
+
330
+ // ══════════════════════════════════════════════════════════
331
+ // Apply all button
332
+ // ══════════════════════════════════════════════════════════
333
+ btnApplyAll.addEventListener('click', () => {
334
+ if (currentSuggestions.length === 0) return;
335
+
336
+ // MED-2: Block if stale
337
+ if (isStale) {
338
+ showToast('⚠ أعد التحليل أولاً — النص تغيّر');
339
+ return;
340
+ }
341
+
342
+ // Apply all patches using reverse-order (no rebase needed — all applied at once)
343
+ analyzedText = applyAllPatches(analyzedText, currentSuggestions);
344
+ inputText.value = analyzedText;
345
+ updateCounts(inputText, charCount, wordCount);
346
+ currentSuggestions = [];
347
+ resultText.innerHTML = escapeHtml(analyzedText);
348
+ updateScore(0, 0, 0);
349
+ renderSuggestions([]);
350
+ showToast('✓ تم تطبيق جميع التصحيحات');
351
+ });
352
+
353
+ // ══════════════════════════════════════════════════════════
354
+ // Copy result (MED-1: .catch() for clipboard errors)
355
+ // ══════════════════════════════════════════════════════════
356
+ btnCopyResult.addEventListener('click', () => {
357
+ const text = resultText.textContent || '';
358
+ navigator.clipboard.writeText(text)
359
+ .then(() => showToast('✓ تم نسخ النص'))
360
+ .catch(() => showToast('تعذّر النسخ'));
361
+ });
362
+
363
+ // ══════════════════════════════════════════════════════════
364
+ // Summarize button
365
+ // ══════════════════════════════════════════════════════════
366
+ btnSummarize.addEventListener('click', async () => {
367
+ const text = summaryInputText.value.trim();
368
+ if (!text) {
369
+ showToast('أدخل نصاً للتلخيص');
370
+ return;
371
+ }
372
+ if (text.length < CONFIG.MIN_SUMMARIZE_LENGTH) {
373
+ showToast('النص قصير جداً للتلخيص');
374
+ return;
375
+ }
376
+
377
+ const lengthValue = parseInt(document.querySelector('input[name="summary-length"]:checked')?.value || '2', 10);
378
+
379
+ setLoading(true, 'جارٍ التلخيص...');
380
+
381
+ try {
382
+ const data = await bayanSummarize(text, lengthValue);
383
+
384
+ if (data.status === 'success' && data.summary) {
385
+ summaryResultSection.classList.remove('is-hidden');
386
+ summaryText.textContent = data.summary;
387
+ summaryMeta.textContent = `النص الأصلي: ${(data.original_length || 0).toLocaleString('ar-EG')} حرف → الملخص: ${(data.summary_length || 0).toLocaleString('ar-EG')} حرف`;
388
+ showToast('✓ تم التلخيص');
389
+ } else {
390
+ showToast('تعذّر التلخيص — حاول مرة أخرى');
391
+ }
392
+ } catch (error) {
393
+ console.error('[Bayan] Summarization error:', error);
394
+ showToast('خطأ في الاتصال — تحقق من الإنترنت');
395
+ } finally {
396
+ setLoading(false);
397
+ }
398
+ });
399
+
400
+ // ══════════════════════════════════════════════════════════
401
+ // Copy summary (MED-1: .catch() for clipboard errors)
402
+ // ══════════════════════════════════════════════════════════
403
+ btnCopySummary.addEventListener('click', () => {
404
+ const text = summaryText.textContent || '';
405
+ navigator.clipboard.writeText(text)
406
+ .then(() => showToast('✓ تم نسخ الملخص'))
407
+ .catch(() => showToast('تعذّر النسخ'));
408
+ });
409
+
410
+ // ══════════════════════════��═══════════════════════════════
411
+ // Status check on load
412
+ // ══════════════════════════════════════════════════════════
413
+ (async function checkStatus() {
414
+ const statusDot = document.querySelector('.bayan-status-dot');
415
+ const statusText = document.getElementById('status-text');
416
+ try {
417
+ await bayanHealthCheck();
418
+ if (statusDot) statusDot.classList.add('online');
419
+ if (statusText) statusText.textContent = 'متصل';
420
+ } catch {
421
+ if (statusDot) statusDot.classList.add('offline');
422
+ if (statusText) statusText.textContent = 'غير متصل';
423
+ }
424
+ })();
425
+
426
+ // ══════════════════════════════════════════════════════════
427
+ // Phase 4: Context Menu Pickup
428
+ // ══════════════════════════════════════════════════════════
429
+ // Fix #2: Guard against double execution
430
+ // Fix #4: Tab name constants
431
+ // Fix #5: Storage fallback (session → local)
432
+ // ══════════════════════════════════════════════════════════
433
+
434
+ const TAB_ACTIONS = { correct: 'correct', summarize: 'summarize' };
435
+ let contextConsumed = false; // Fix #2
436
+
437
+ (async function checkContextAction() {
438
+ // Only available in extension context (not in test pages)
439
+ if (typeof chrome === 'undefined' || !chrome.storage) return;
440
+
441
+ // Fix #2: Prevent double-trigger
442
+ if (contextConsumed) return;
443
+
444
+ // Fix #5: Storage fallback
445
+ const storage = chrome.storage?.session || chrome.storage?.local;
446
+ if (!storage) return;
447
+
448
+ try {
449
+ const data = await storage.get(['contextAction', 'contextText', 'contextTimestamp']);
450
+ if (!data.contextAction || !data.contextText) return;
451
+
452
+ // Ignore stale actions (older than 15 seconds — matches background cleanup)
453
+ const age = Date.now() - (data.contextTimestamp || 0);
454
+ if (age > 15000) {
455
+ chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
456
+ return;
457
+ }
458
+
459
+ // Fix #2: Mark as consumed BEFORE processing
460
+ contextConsumed = true;
461
+
462
+ console.log(`[Bayan] Context action: ${data.contextAction}, text length: ${data.contextText.length}`);
463
+
464
+ if (data.contextAction === TAB_ACTIONS.correct) {
465
+ // Fill the correction tab and auto-trigger analysis
466
+ inputText.value = data.contextText;
467
+ updateCounts(inputText, charCount, wordCount);
468
+
469
+ // Switch to correction tab
470
+ const correctTab = document.querySelector(`[data-tab="${TAB_ACTIONS.correct}"]`);
471
+ if (correctTab) correctTab.click();
472
+
473
+ // Auto-click the correct button after a brief delay for UI paint
474
+ setTimeout(() => btnCorrect.click(), 150);
475
+
476
+ } else if (data.contextAction === TAB_ACTIONS.summarize) {
477
+ // Fill the summary tab and auto-trigger summarization
478
+ summaryInputText.value = data.contextText;
479
+ updateCounts(summaryInputText, summaryCharCount, null);
480
+
481
+ // Switch to summarize tab
482
+ const summarizeTab = document.querySelector(`[data-tab="${TAB_ACTIONS.summarize}"]`);
483
+ if (summarizeTab) summarizeTab.click();
484
+
485
+ // Auto-click the summarize button
486
+ setTimeout(() => btnSummarize.click(), 150);
487
+ }
488
+
489
+ // Clear the context action so it doesn't re-trigger on next popup open
490
+ chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
491
+
492
+ } catch (err) {
493
+ console.warn('[Bayan] Context action check failed:', err);
494
+ }
495
+ })();
496
+ });
497
+
extension/shared/analysis-controller.js ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan — Analysis Controller (Phase 7.1 Simplified)
3
+ *
4
+ * Responsibilities (ONLY):
5
+ * - Adaptive debounce (2-tier: normal / large text)
6
+ * - Stale request cancellation
7
+ * - Text validation
8
+ * - Message passing to background.js
9
+ *
10
+ * Does NOT own:
11
+ * - Caching (background.js)
12
+ * - Retry logic (background.js)
13
+ * - Timeout (background.js)
14
+ * - Network (background.js)
15
+ *
16
+ * Requires: BAYAN (shared/constants.js)
17
+ */
18
+
19
+ // eslint-disable-next-line no-unused-vars
20
+ const BayanController = (() => {
21
+ 'use strict';
22
+
23
+ // ── Debounce config ──
24
+ const DEBOUNCE_NORMAL = 500;
25
+ const DEBOUNCE_LARGE = 1000;
26
+ const LARGE_TEXT_THRESHOLD = 2000;
27
+
28
+ // ── State ──
29
+ let _debounceTimer = null;
30
+ let _inflight = false;
31
+
32
+ // ── Protected site check ──
33
+ const _isProtected = BAYAN.PROTECTED_HOSTS.includes(window.location.hostname);
34
+
35
+ // ══════════════════════════════════════════════════════════
36
+ // Text validation
37
+ // ══════════════════════════════════════════════════════════
38
+
39
+ function hasArabic(text) {
40
+ if (!text || text.length < BAYAN.MIN_TEXT_LENGTH) return false;
41
+ const arabicChars = (text.match(/[\u0600-\u06FF]/g) || []).length;
42
+ return arabicChars >= BAYAN.MIN_ARABIC_CHARS;
43
+ }
44
+
45
+ function validateText(text) {
46
+ if (!text) return { valid: false, reason: 'empty' };
47
+ if (text.length < BAYAN.MIN_TEXT_LENGTH) return { valid: false, reason: 'too_short' };
48
+ if (text.length > BAYAN.MAX_TEXT_LENGTH) return { valid: false, reason: 'too_long' };
49
+ if (!hasArabic(text)) return { valid: false, reason: 'no_arabic' };
50
+ return { valid: true };
51
+ }
52
+
53
+ // ══════════════════════════════════════════════════════════
54
+ // Core: debounced analysis via background bridge
55
+ // ══════════════════════════════════════════════════════════
56
+
57
+ /**
58
+ * Schedule a debounced analysis.
59
+ * Background.js owns caching, retry, and timeout.
60
+ *
61
+ * @param {string} text
62
+ * @param {function(Object|null): void} onResult
63
+ * @param {function(): string} getCurrentText — staleness checker
64
+ */
65
+ function scheduleAnalysis(text, onResult, getCurrentText) {
66
+ if (_debounceTimer) {
67
+ clearTimeout(_debounceTimer);
68
+ _debounceTimer = null;
69
+ }
70
+
71
+ if (!validateText(text).valid) {
72
+ onResult(null);
73
+ return;
74
+ }
75
+
76
+ const delay = text.length > LARGE_TEXT_THRESHOLD ? DEBOUNCE_LARGE : DEBOUNCE_NORMAL;
77
+
78
+ _debounceTimer = setTimeout(() => {
79
+ _debounceTimer = null;
80
+
81
+ // Staleness check: text changed during debounce
82
+ if (getCurrentText() !== text) return;
83
+
84
+ executeAnalysis(text, onResult, getCurrentText);
85
+ }, delay);
86
+ }
87
+
88
+ /**
89
+ * Send analysis request to background.js.
90
+ * No timeout, no retry, no cache — background handles all of that.
91
+ */
92
+ function executeAnalysis(text, onResult, getCurrentText) {
93
+ _inflight = true;
94
+
95
+ chrome.runtime.sendMessage({ type: 'INLINE_ANALYZE', text }, (response) => {
96
+ _inflight = false;
97
+
98
+ // Extension context invalidated (e.g., extension reloaded)
99
+ if (chrome.runtime.lastError) {
100
+ console.warn('[Bayan Controller]', chrome.runtime.lastError.message);
101
+ onResult(null);
102
+ return;
103
+ }
104
+
105
+ // Staleness check: text changed during fetch
106
+ if (getCurrentText() !== text) return;
107
+
108
+ if (!response || response.error) {
109
+ onResult(null);
110
+ return;
111
+ }
112
+
113
+ onResult(response.data);
114
+ });
115
+ }
116
+
117
+ // ══════════════════════════════════════════════════════════
118
+ // Cancellation
119
+ // ══════════════════════════════════════════════════════════
120
+
121
+ function cancelAll() {
122
+ if (_debounceTimer) {
123
+ clearTimeout(_debounceTimer);
124
+ _debounceTimer = null;
125
+ }
126
+ _inflight = false;
127
+ }
128
+
129
+ function destroy() {
130
+ cancelAll();
131
+ }
132
+
133
+ // ══════════════════════════════════════════════════════════
134
+ // Public API
135
+ // ══════════════════════════════════════════════════════════
136
+
137
+ return {
138
+ scheduleAnalysis,
139
+ cancelAll,
140
+ destroy,
141
+ validateText,
142
+ hasArabic,
143
+ isProtectedSite() { return _isProtected; },
144
+ isInFlight() { return _inflight; },
145
+ };
146
+ })();
extension/shared/bayan-api.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — API Client
3
+ *
4
+ * Reused from: src/js/api.js (L2-20)
5
+ * Adaptation: Relative paths replaced with CONFIG.API_BASE.
6
+ * Only analyzeText and summarizeText are needed (Phase 0 audit).
7
+ */
8
+
9
+ /**
10
+ * Send text to the unified analysis pipeline.
11
+ * Backend: /api/analyze (Spelling → Grammar → Punctuation)
12
+ *
13
+ * @param {string} text - Arabic text to analyze
14
+ * @param {AbortSignal} [signal] - Optional abort signal
15
+ * @returns {Promise<Object>} { original, corrected, suggestions[], timing_ms, status }
16
+ */
17
+ async function bayanAnalyze(text, signal) {
18
+ const response = await fetch(`${CONFIG.API_BASE}/api/analyze`, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ text }),
22
+ signal,
23
+ });
24
+ if (!response.ok) {
25
+ throw new Error(`Analyze API error: ${response.status}`);
26
+ }
27
+ return await response.json();
28
+ }
29
+
30
+ /**
31
+ * Summarize Arabic text.
32
+ * Backend: /api/summarize
33
+ *
34
+ * @param {string} text - Arabic text to summarize
35
+ * @param {number} [length=2] - Summary length (1=short, 2=medium, 3=long)
36
+ * @param {boolean} [fullText=true] - Summarize full text or first paragraph
37
+ * @returns {Promise<Object>} { summary, status, original_length, summary_length }
38
+ */
39
+ async function bayanSummarize(text, length = 2, fullText = true) {
40
+ const response = await fetch(`${CONFIG.API_BASE}/api/summarize`, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ text, length, full_text: fullText }),
44
+ });
45
+ if (!response.ok) {
46
+ throw new Error(`Summarize API error: ${response.status}`);
47
+ }
48
+ return await response.json();
49
+ }
50
+
51
+ /**
52
+ * Check backend health.
53
+ * Backend: /api/health
54
+ *
55
+ * @returns {Promise<Object>} { status, mode, models }
56
+ */
57
+ async function bayanHealthCheck() {
58
+ const response = await fetch(`${CONFIG.API_BASE}/api/health`, {
59
+ method: 'GET',
60
+ });
61
+ if (!response.ok) {
62
+ throw new Error(`Health check error: ${response.status}`);
63
+ }
64
+ return await response.json();
65
+ }
extension/shared/bayan-patches.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — Patch Application Engine
3
+ *
4
+ * Algorithms reused from: src/js/editor.js
5
+ * Core logic: reverse-order offset replacement (editor.js L674-710)
6
+ *
7
+ * This module applies suggestion corrections to plain text
8
+ * without requiring DOM manipulation.
9
+ *
10
+ * ═══════════════════════════════════════════════════════════
11
+ * HIGH-1 FIX — Offset Rebasing Strategy
12
+ * ═══════════════════════════════════════════════════════════
13
+ *
14
+ * When applying a single correction, the text length may change.
15
+ * All remaining suggestions whose offsets come AFTER the applied
16
+ * patch must be shifted by the length difference (delta).
17
+ *
18
+ * Strategy: LINEAR SHIFT
19
+ * delta = replacementText.length - (suggestion.end - suggestion.start)
20
+ * For every remaining suggestion S where S.start >= applied.end:
21
+ * S.start += delta
22
+ * S.end += delta
23
+ *
24
+ * Edge cases:
25
+ * - Shorter replacement (delta < 0): offsets shift left
26
+ * - Longer replacement (delta > 0): offsets shift right
27
+ * - Same-length (delta = 0): offsets unchanged
28
+ * - Overlapping suggestions: handled by PatchSet.resolve_overlaps()
29
+ * on the backend — we never receive overlapping suggestions
30
+ *
31
+ * This is O(n) per apply, which is fine for ≤50 suggestions.
32
+ * ═══════════════════════════════════════════════════════════
33
+ */
34
+
35
+ /**
36
+ * Apply a single suggestion patch to text.
37
+ * Source: src/js/editor.js L506-510 (offset-based replacement)
38
+ *
39
+ * @param {string} text - Original text
40
+ * @param {Object} suggestion - { start, end, correction }
41
+ * @returns {string} Text with correction applied
42
+ */
43
+ function applyPatch(text, suggestion) {
44
+ const before = text.substring(0, suggestion.start);
45
+ const after = text.substring(suggestion.end);
46
+ return before + suggestion.correction + after;
47
+ }
48
+
49
+ /**
50
+ * Apply all suggestion patches to text.
51
+ * CRITICAL: Sort in REVERSE order (highest start offset first)
52
+ * to prevent offset shifts from invalidating subsequent patches.
53
+ * Source: src/js/editor.js L674-710 (reverse-order algorithm)
54
+ *
55
+ * @param {string} text - Original text
56
+ * @param {Array} suggestions - Array of { start, end, correction }
57
+ * @returns {string} Fully corrected text
58
+ */
59
+ function applyAllPatches(text, suggestions) {
60
+ if (!suggestions || suggestions.length === 0) return text;
61
+
62
+ // CRITICAL: Sort in REVERSE order (highest start offset first).
63
+ // This is the exact same algorithm from editor.js L676.
64
+ const sorted = [...suggestions].sort((a, b) => b.start - a.start);
65
+
66
+ let result = text;
67
+ sorted.forEach((s) => {
68
+ result = result.substring(0, s.start) + s.correction + result.substring(s.end);
69
+ });
70
+
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * Apply a specific alternative correction to text.
76
+ * Source: src/js/editor.js L547-600 (alternative correction pattern)
77
+ *
78
+ * @param {string} text - Original text
79
+ * @param {Object} suggestion - The suggestion being corrected
80
+ * @param {string} alternativeText - The alternative correction to apply
81
+ * @returns {string} Text with alternative correction applied
82
+ */
83
+ function applyAlternativePatch(text, suggestion, alternativeText) {
84
+ const before = text.substring(0, suggestion.start);
85
+ const after = text.substring(suggestion.end);
86
+ return before + alternativeText + after;
87
+ }
88
+
89
+ /**
90
+ * Apply ONE correction and rebase ALL remaining suggestions.
91
+ * This is the primary function for individual suggestion applies.
92
+ *
93
+ * HIGH-1 FIX: Atomic apply + rebase in a single call.
94
+ *
95
+ * @param {string} text - Current text (must match suggestion offsets)
96
+ * @param {Object} appliedSuggestion - The suggestion being applied
97
+ * @param {string} replacementText - The text to insert (correction or alternative)
98
+ * @param {Array} allSuggestions - All current suggestions (including the one being applied)
99
+ * @returns {{ text: string, suggestions: Array }} Updated text and rebased suggestions
100
+ */
101
+ function applyAndRebase(text, appliedSuggestion, replacementText, allSuggestions) {
102
+ // 1. Apply the patch to text
103
+ const newText = text.substring(0, appliedSuggestion.start)
104
+ + replacementText
105
+ + text.substring(appliedSuggestion.end);
106
+
107
+ // 2. Calculate delta: how much the text length changed
108
+ const originalSpanLength = appliedSuggestion.end - appliedSuggestion.start;
109
+ const delta = replacementText.length - originalSpanLength;
110
+
111
+ // 3. Remove the applied suggestion and rebase remaining offsets
112
+ const rebased = allSuggestions
113
+ .filter((s) => s.id !== appliedSuggestion.id)
114
+ .map((s) => {
115
+ // Only shift suggestions that start AT or AFTER the applied patch's end.
116
+ // Suggestions entirely before the patch are unaffected.
117
+ // (Backend guarantees no overlapping suggestions via PatchSet.resolve_overlaps())
118
+ if (s.start >= appliedSuggestion.end) {
119
+ return { ...s, start: s.start + delta, end: s.end + delta };
120
+ }
121
+ return s;
122
+ });
123
+
124
+ return { text: newText, suggestions: rebased };
125
+ }
126
+
127
+ /**
128
+ * Remove a suggestion from the list (after applying or dismissing).
129
+ * Source: src/js/editor.js L520-523 (UUID-based filter)
130
+ *
131
+ * @param {Array} suggestions - Current suggestions array
132
+ * @param {string} suggestionId - ID of suggestion to remove
133
+ * @returns {Array} Filtered suggestions
134
+ */
135
+ function removeSuggestion(suggestions, suggestionId) {
136
+ return suggestions.filter((s) => s.id !== suggestionId);
137
+ }
138
+
139
+ /**
140
+ * Count suggestions by type.
141
+ * Source: src/js/editor.js L525-527 (type counting)
142
+ *
143
+ * @param {Array} suggestions
144
+ * @returns {{ spelling: number, grammar: number, punctuation: number }}
145
+ */
146
+ function countByType(suggestions) {
147
+ return {
148
+ spelling: suggestions.filter((s) => s.type === 'spelling').length,
149
+ grammar: suggestions.filter((s) => s.type === 'grammar').length,
150
+ punctuation: suggestions.filter((s) => s.type === 'punctuation').length,
151
+ };
152
+ }
extension/shared/bayan-renderer.js ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — Renderer
3
+ *
4
+ * Functions reused from: src/js/renderer.js
5
+ * All functions preserve original behavior exactly.
6
+ * Source line references documented per Phase 0 audit.
7
+ */
8
+
9
+ /**
10
+ * Escapes HTML special characters to prevent XSS.
11
+ * Source: src/js/renderer.js L9-18 (direct copy)
12
+ *
13
+ * @param {string} text - Text to escape
14
+ * @returns {string} Escaped text
15
+ */
16
+ function escapeHtml(text) {
17
+ const map = {
18
+ '&': '&amp;',
19
+ '<': '&lt;',
20
+ '>': '&gt;',
21
+ '"': '&quot;',
22
+ "'": '&#039;',
23
+ };
24
+ return text.replace(/[&<>"']/g, (c) => map[c]);
25
+ }
26
+
27
+ /**
28
+ * Sorts suggestions by start offset.
29
+ * Source: src/js/renderer.js L25-27 (direct copy)
30
+ *
31
+ * @param {Array} suggestions - Array of suggestions with start/end offsets
32
+ * @returns {Array} Sorted suggestions
33
+ */
34
+ function sortSuggestions(suggestions) {
35
+ return [...suggestions].sort((a, b) => a.start - b.start);
36
+ }
37
+
38
+ /**
39
+ * Gets CSS class for suggestion type.
40
+ * Source: src/js/renderer.js L138-145 (direct copy)
41
+ *
42
+ * @param {string} type - Suggestion type (spelling, grammar, punctuation)
43
+ * @returns {string} CSS class name
44
+ */
45
+ function getErrorClass(type) {
46
+ const classes = {
47
+ spelling: 'bayan-spelling-error',
48
+ grammar: 'bayan-grammar-error',
49
+ punctuation: 'bayan-punctuation-suggestion',
50
+ };
51
+ return classes[type] || 'bayan-spelling-error';
52
+ }
53
+
54
+ /**
55
+ * Creates a segment tree of text ranges with their suggestions.
56
+ * Handles overlapping ranges by merging them.
57
+ * Source: src/js/renderer.js L36-131 (direct copy)
58
+ *
59
+ * @param {string} text - Original text
60
+ * @param {Array} suggestions - Array of suggestions with start/end offsets
61
+ * @returns {Array} Array of segments with position and suggestion info
62
+ */
63
+ function createSegments(text, suggestions) {
64
+ const sorted = sortSuggestions(suggestions);
65
+ const finalSegments = [];
66
+ let segStart = 0;
67
+
68
+ sorted.forEach((suggestion) => {
69
+ const { start, end } = suggestion;
70
+
71
+ // Add text before suggestion
72
+ if (segStart < start) {
73
+ finalSegments.push({
74
+ type: 'text',
75
+ text: text.slice(segStart, start),
76
+ suggestions: [],
77
+ });
78
+ }
79
+
80
+ // Add suggested text
81
+ finalSegments.push({
82
+ type: 'suggestion',
83
+ text: text.slice(start, end),
84
+ suggestion: suggestion,
85
+ });
86
+
87
+ segStart = end;
88
+ });
89
+
90
+ // Add remaining text
91
+ if (segStart < text.length) {
92
+ finalSegments.push({
93
+ type: 'text',
94
+ text: text.slice(segStart),
95
+ suggestions: [],
96
+ });
97
+ }
98
+
99
+ return finalSegments;
100
+ }
101
+
102
+ /**
103
+ * Renders text with highlighted suggestions as HTML string.
104
+ * Source: src/js/renderer.js L153-191 (adapted for extension CSS classes)
105
+ *
106
+ * @param {string} text - Original text
107
+ * @param {Array} suggestions - Array of suggestions with start/end offsets
108
+ * @returns {string} Safe HTML string with highlights
109
+ */
110
+ function renderHighlightedText(text, suggestions) {
111
+ if (!text || text.length === 0) return '';
112
+ if (!suggestions || suggestions.length === 0) return escapeHtml(text);
113
+
114
+ const segments = createSegments(text, suggestions);
115
+ let html = '';
116
+
117
+ segments.forEach((segment) => {
118
+ if (segment.type === 'text') {
119
+ html += escapeHtml(segment.text);
120
+ } else if (segment.type === 'suggestion') {
121
+ const { suggestion } = segment;
122
+ const errorClass = getErrorClass(suggestion.type);
123
+ const escapedText = escapeHtml(segment.text);
124
+ const sid = suggestion.id || '';
125
+
126
+ html += `<span class="${errorClass}" data-suggestion-id="${sid}" data-original="${escapeHtml(
127
+ suggestion.original
128
+ )}" data-correction="${escapeHtml(
129
+ suggestion.correction
130
+ )}" data-type="${suggestion.type}" title="${suggestion.type}: ${escapeHtml(
131
+ suggestion.correction
132
+ )}">${escapedText}</span>`;
133
+ }
134
+ });
135
+
136
+ return html;
137
+ }
138
+
139
+ /**
140
+ * Main renderer function.
141
+ * Source: src/js/renderer.js L201-204 (direct copy)
142
+ *
143
+ * @param {Object} input - { text, suggestions }
144
+ * @returns {string} Safe HTML with highlights
145
+ */
146
+ function bayanRender(input) {
147
+ const { text = '', suggestions = [] } = input;
148
+ return renderHighlightedText(text, suggestions);
149
+ }
extension/shared/bayan-state.js ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — Unified State Manager
3
+ *
4
+ * Phase 7: Production Hardening
5
+ *
6
+ * Single source of truth for runtime state across the inline engine.
7
+ * Loaded as a content script BEFORE content-inline.js.
8
+ *
9
+ * Responsibilities:
10
+ * - Track active field reference (WeakRef for GC safety)
11
+ * - Store last analyzed text + response
12
+ * - Track active mode
13
+ * - Provide clean teardown
14
+ */
15
+
16
+ // eslint-disable-next-line no-unused-vars
17
+ const BayanState = (() => {
18
+ 'use strict';
19
+
20
+ /** @type {WeakRef<HTMLElement>|null} */
21
+ let _fieldRef = null;
22
+ let _lastText = '';
23
+ let _lastHash = '';
24
+ let _lastResponse = null;
25
+ /** @type {Array} */
26
+ let _suggestions = [];
27
+ let _mode = 'idle'; // idle | inline | sidepanel | contextmenu
28
+ let _paused = false;
29
+ let _pauseReason = '';
30
+
31
+ /**
32
+ * Simple FNV-1a hash for text deduplication.
33
+ * 32-bit, fast, zero dependencies.
34
+ * @param {string} str
35
+ * @returns {string}
36
+ */
37
+ function hash(str) {
38
+ let h = 0x811c9dc5;
39
+ for (let i = 0; i < str.length; i++) {
40
+ h ^= str.charCodeAt(i);
41
+ h = Math.imul(h, 0x01000193);
42
+ }
43
+ return (h >>> 0).toString(36);
44
+ }
45
+
46
+ return {
47
+ // ── Field management ──
48
+ /** @param {HTMLElement} field */
49
+ setField(field) {
50
+ _fieldRef = field ? new WeakRef(field) : null;
51
+ },
52
+
53
+ /** @returns {HTMLElement|null} */
54
+ getField() {
55
+ return _fieldRef?.deref() ?? null;
56
+ },
57
+
58
+ hasField() {
59
+ return !!(_fieldRef?.deref());
60
+ },
61
+
62
+ // ── Text + hash ──
63
+ setLastText(text) {
64
+ _lastText = text;
65
+ _lastHash = hash(text);
66
+ },
67
+ getLastText() { return _lastText; },
68
+ getLastHash() { return _lastHash; },
69
+
70
+ /**
71
+ * Check if given text is identical to last analyzed text.
72
+ * Uses hash comparison (O(1)) instead of string equality (O(n)).
73
+ */
74
+ isDuplicate(text) {
75
+ return hash(text) === _lastHash && text === _lastText;
76
+ },
77
+
78
+ // ── Response cache ──
79
+ setLastResponse(data) { _lastResponse = data; },
80
+ getLastResponse() { return _lastResponse; },
81
+
82
+ // ── Suggestions ──
83
+ setSuggestions(suggestions) { _suggestions = suggestions || []; },
84
+ getSuggestions() { return _suggestions; },
85
+
86
+ // ── Mode ──
87
+ setMode(mode) { _mode = mode; },
88
+ getMode() { return _mode; },
89
+
90
+ // ── Pause state ──
91
+ pause(reason) {
92
+ _paused = true;
93
+ _pauseReason = reason || 'unknown';
94
+ },
95
+ resume() {
96
+ _paused = false;
97
+ _pauseReason = '';
98
+ },
99
+ isPaused() { return _paused; },
100
+ getPauseReason() { return _pauseReason; },
101
+
102
+ // ── Clean teardown ──
103
+ reset() {
104
+ _fieldRef = null;
105
+ _lastText = '';
106
+ _lastHash = '';
107
+ _lastResponse = null;
108
+ _suggestions = [];
109
+ _mode = 'idle';
110
+ _paused = false;
111
+ _pauseReason = '';
112
+ },
113
+
114
+ // ── Debug ──
115
+ toJSON() {
116
+ return {
117
+ hasField: !!(_fieldRef?.deref()),
118
+ textLength: _lastText.length,
119
+ hash: _lastHash,
120
+ suggestions: _suggestions.length,
121
+ mode: _mode,
122
+ paused: _paused,
123
+ pauseReason: _pauseReason,
124
+ };
125
+ },
126
+ };
127
+ })();
extension/shared/bayan-ui.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — UI Helpers
3
+ *
4
+ * Functions reused from: src/js/ui.js
5
+ * All functions preserve original behavior exactly.
6
+ * Source line references documented per Phase 0 audit.
7
+ */
8
+
9
+ /**
10
+ * Arabic labels for suggestion types.
11
+ * Source: src/js/ui.js L3-7 (direct copy)
12
+ */
13
+ const TYPE_LABELS = {
14
+ spelling: 'إملائي',
15
+ grammar: 'نحوي',
16
+ punctuation: 'ترقيم',
17
+ };
18
+
19
+ /**
20
+ * Resolve alternatives for a suggestion, falling back to [correction, original].
21
+ * Source: src/js/ui.js L67-71 (direct copy)
22
+ *
23
+ * @param {Object} suggestion
24
+ * @returns {Array<string>}
25
+ */
26
+ function resolveAlternatives(suggestion) {
27
+ return suggestion.alternatives && suggestion.alternatives.length > 0
28
+ ? suggestion.alternatives
29
+ : [suggestion.correction, suggestion.original];
30
+ }
31
+
32
+ /**
33
+ * Calculate writing score from suggestion counts.
34
+ * Source: src/js/ui.js L14-17 (direct copy)
35
+ *
36
+ * @param {number} spelling
37
+ * @param {number} grammar
38
+ * @param {number} punctuation
39
+ * @returns {number} Score 0-100
40
+ */
41
+ function calculateWritingScore(spelling, grammar, punctuation) {
42
+ const score = 100 - spelling * 8 - grammar * 6 - punctuation * 3;
43
+ return Math.max(0, Math.min(100, score));
44
+ }
45
+
46
+ /**
47
+ * Build HTML for a single suggestion card.
48
+ * Source: src/js/ui.js L73-100 (adapted for extension CSS classes)
49
+ *
50
+ * @param {Object} suggestion
51
+ * @param {number} index
52
+ * @returns {string} HTML string
53
+ */
54
+ function buildSuggestionCardHTML(suggestion, index) {
55
+ const badgeClass = `bayan-badge-${suggestion.type}`;
56
+ const label = TYPE_LABELS[suggestion.type] || suggestion.type;
57
+ const alts = resolveAlternatives(suggestion);
58
+ const suggestionId = suggestion.id || index;
59
+
60
+ let altsHTML = '';
61
+ alts.forEach((alt, i) => {
62
+ const isKeep = alt === suggestion.original;
63
+ const isMain = i === 0;
64
+ const cls = isKeep
65
+ ? 'bayan-alt-chip bayan-alt-chip--keep'
66
+ : isMain
67
+ ? 'bayan-alt-chip bayan-alt-chip--main'
68
+ : 'bayan-alt-chip';
69
+ const chipLabel = isKeep ? `${escapeHtml(alt)} ✓` : escapeHtml(alt);
70
+ altsHTML += `<button class="${cls}" data-card-alt="${escapeHtml(alt)}" data-card-id="${suggestionId}" type="button">${chipLabel}</button>`;
71
+ });
72
+
73
+ return `
74
+ <div class="bayan-suggestion-card" role="listitem" tabindex="0"
75
+ data-suggestion-id="${suggestionId}"
76
+ aria-label="${label}: ${escapeHtml(suggestion.original)} إلى ${escapeHtml(suggestion.correction)}">
77
+ <span class="bayan-suggestion-badge ${badgeClass}">${label}</span>
78
+ <div class="bayan-suggestion-change">
79
+ <span class="bayan-suggestion-original">${escapeHtml(suggestion.original)}</span>
80
+ <span class="bayan-suggestion-arrow">←</span>
81
+ <span class="bayan-suggestion-correction">${escapeHtml(suggestion.correction)}</span>
82
+ </div>
83
+ <div class="bayan-suggestion-alts">${altsHTML}</div>
84
+ </div>`;
85
+ }
86
+
87
+ /**
88
+ * Generate score hint text.
89
+ *
90
+ * @param {number} score
91
+ * @param {number} total - total suggestion count
92
+ * @returns {string}
93
+ */
94
+ function getScoreHint(score, total) {
95
+ if (total === 0) return 'ابدأ الكتابة لرؤية تقييمك';
96
+ if (score >= 90) return 'كتابة ممتازة! استمر.';
97
+ if (score >= 70) return 'جيد — راجع الاقتراحات لتحسين النص.';
98
+ return 'يحتاج النص إلى بعض التحسينات.';
99
+ }
extension/shared/config.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan — Legacy Config (popup.js / sidepanel.js compatibility)
3
+ *
4
+ * This file provides the CONFIG object expected by popup.js and sidepanel.js.
5
+ * All values are derived from BAYAN constants (shared/constants.js) where available,
6
+ * or defined here for popup/sidepanel-specific settings.
7
+ *
8
+ * NOTE: popup.html and sidepanel.html load constants.js BEFORE this file,
9
+ * so BAYAN is available here.
10
+ */
11
+ const CONFIG = {
12
+ API_BASE: typeof BAYAN !== 'undefined' ? BAYAN.API_BASE : 'https://bayan10-bayan-api.hf.space',
13
+ MAX_ANALYZE_LENGTH: typeof BAYAN !== 'undefined' ? BAYAN.MAX_TEXT_LENGTH : 5000,
14
+ MIN_ANALYZE_LENGTH: typeof BAYAN !== 'undefined' ? BAYAN.MIN_TEXT_LENGTH : 15,
15
+ ANALYZE_DEBOUNCE_MS: 3000,
16
+ MIN_SUMMARIZE_LENGTH: typeof BAYAN !== 'undefined' ? BAYAN.MIN_SUMMARIZE_LENGTH : 10,
17
+ };
extension/shared/constants.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan — Shared Constants (Single Source of Truth)
3
+ *
4
+ * ALL shared configuration lives here.
5
+ * No hardcoded API URLs, timeouts, or limits elsewhere.
6
+ *
7
+ * Used by: background.js, analysis-controller.js, config.js (legacy alias)
8
+ */
9
+ const BAYAN = {
10
+ /** Backend API base URL (HuggingFace Spaces) */
11
+ API_BASE: 'https://bayan10-bayan-api.hf.space',
12
+
13
+ /** Network timeout for API calls (ms) */
14
+ API_TIMEOUT_MS: 20000,
15
+
16
+ /** Max retries on network failure */
17
+ MAX_RETRIES: 1,
18
+
19
+ /** Cache TTL (ms) — 5 minutes */
20
+ CACHE_TTL_MS: 300000,
21
+
22
+ /** Max cache entries */
23
+ CACHE_MAX_ENTRIES: 20,
24
+
25
+ /** Max text length for analysis */
26
+ MAX_TEXT_LENGTH: 5000,
27
+
28
+ /** Min text length to trigger analysis */
29
+ MIN_TEXT_LENGTH: 15,
30
+
31
+ /** Min Arabic characters required */
32
+ MIN_ARABIC_CHARS: 5,
33
+
34
+ /** Min text length for summarization */
35
+ MIN_SUMMARIZE_LENGTH: 10,
36
+
37
+ /** Protected hosts — disable contenteditable injection */
38
+ PROTECTED_HOSTS: [
39
+ 'mail.google.com',
40
+ 'docs.google.com',
41
+ 'sheets.google.com',
42
+ 'slides.google.com',
43
+ 'notion.so',
44
+ 'www.notion.so',
45
+ ],
46
+ };
extension/shared/hash.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan — FNV-1a Hash (Single Implementation)
3
+ *
4
+ * 32-bit FNV-1a hash. Fast, zero dependencies.
5
+ * Used for text deduplication in cache layers.
6
+ *
7
+ * @param {string} str
8
+ * @returns {string} Base-36 hash string
9
+ */
10
+ function bayanHash(str) {
11
+ let h = 0x811c9dc5;
12
+ for (let i = 0; i < str.length; i++) {
13
+ h ^= str.charCodeAt(i);
14
+ h = Math.imul(h, 0x01000193);
15
+ }
16
+ return (h >>> 0).toString(36);
17
+ }
extension/sidepanel/sidepanel.css ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════
2
+ Bayan Side Panel — Styles
3
+ Persistent workspace | RTL | Dark premium
4
+ ══════════════════════════════════════════════ */
5
+
6
+ /* ── Design Tokens (shared with popup) ── */
7
+ :root {
8
+ --sp-primary: #6366f1;
9
+ --sp-primary-light: #818cf8;
10
+ --sp-primary-dark: #4f46e5;
11
+ --sp-primary-glow: rgba(99, 102, 241, 0.25);
12
+
13
+ --sp-bg: #0f0f14;
14
+ --sp-surface: #1a1a24;
15
+ --sp-surface-hover: #22222e;
16
+ --sp-surface-active: #2a2a38;
17
+ --sp-border: #2d2d3d;
18
+ --sp-border-light: #3a3a4d;
19
+
20
+ --sp-text: #f0f0f5;
21
+ --sp-text-secondary: #9898ad;
22
+ --sp-text-muted: #6b6b80;
23
+
24
+ --sp-success: #22c55e;
25
+ --sp-warning: #f59e0b;
26
+ --sp-error: #ef4444;
27
+
28
+ --sp-spelling: #ef4444;
29
+ --sp-grammar: #f59e0b;
30
+ --sp-punctuation: #3b82f6;
31
+
32
+ --sp-radius: 10px;
33
+ --sp-radius-sm: 6px;
34
+ --sp-radius-lg: 14px;
35
+
36
+ --sp-font: 'Segoe UI', 'SF Pro', Tahoma, Arial, sans-serif;
37
+ --sp-font-arabic: 'Noto Sans Arabic', 'Segoe UI', Tahoma, sans-serif;
38
+
39
+ --sp-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
40
+ --sp-spring: 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
41
+ }
42
+
43
+ /* ── Reset ── */
44
+ *, *::before, *::after {
45
+ box-sizing: border-box;
46
+ margin: 0;
47
+ padding: 0;
48
+ }
49
+
50
+ body {
51
+ width: 100%;
52
+ min-height: 100vh;
53
+ overflow-y: auto;
54
+ overflow-x: hidden;
55
+ font-family: var(--sp-font-arabic);
56
+ font-size: 13px;
57
+ line-height: 1.6;
58
+ color: var(--sp-text);
59
+ background: var(--sp-bg);
60
+ direction: rtl;
61
+ -webkit-font-smoothing: antialiased;
62
+ }
63
+
64
+ /* Custom scrollbar */
65
+ ::-webkit-scrollbar { width: 4px; }
66
+ ::-webkit-scrollbar-track { background: transparent; }
67
+ ::-webkit-scrollbar-thumb { background: var(--sp-border); border-radius: 4px; }
68
+
69
+ /* ══════════════════════════════════════════════
70
+ Header
71
+ ══════════════════════════════════════════════ */
72
+ .sp-header {
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ padding: 10px 14px;
77
+ background: linear-gradient(135deg, var(--sp-surface) 0%, rgba(99, 102, 241, 0.08) 100%);
78
+ border-bottom: 1px solid var(--sp-border);
79
+ position: sticky;
80
+ top: 0;
81
+ z-index: 100;
82
+ backdrop-filter: blur(12px);
83
+ }
84
+
85
+ .sp-header-brand {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 8px;
89
+ }
90
+
91
+ .sp-header-title {
92
+ font-size: 16px;
93
+ font-weight: 700;
94
+ background: linear-gradient(135deg, var(--sp-primary-light), #a78bfa);
95
+ -webkit-background-clip: text;
96
+ -webkit-text-fill-color: transparent;
97
+ letter-spacing: -0.5px;
98
+ }
99
+
100
+ .sp-header-status {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 5px;
104
+ font-size: 11px;
105
+ color: var(--sp-text-muted);
106
+ }
107
+
108
+ .sp-status-dot {
109
+ width: 7px;
110
+ height: 7px;
111
+ border-radius: 50%;
112
+ background: var(--sp-text-muted);
113
+ transition: background var(--sp-transition);
114
+ }
115
+ .sp-status-dot.online { background: var(--sp-success); box-shadow: 0 0 6px var(--sp-success); }
116
+ .sp-status-dot.offline { background: var(--sp-error); }
117
+ .sp-status-text { font-size: 10px; }
118
+
119
+ /* ══════════════════════════════════════════════
120
+ Tabs
121
+ ══════════════════════════════════════════════ */
122
+ .sp-tabs {
123
+ display: flex;
124
+ gap: 0;
125
+ padding: 0 14px;
126
+ background: var(--sp-surface);
127
+ border-bottom: 1px solid var(--sp-border);
128
+ }
129
+
130
+ .sp-tab {
131
+ flex: 1;
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ gap: 5px;
136
+ padding: 8px 0;
137
+ border: none;
138
+ background: transparent;
139
+ color: var(--sp-text-muted);
140
+ font-family: var(--sp-font-arabic);
141
+ font-size: 12px;
142
+ font-weight: 600;
143
+ cursor: pointer;
144
+ border-bottom: 2px solid transparent;
145
+ transition: all var(--sp-transition);
146
+ }
147
+ .sp-tab:hover { color: var(--sp-text-secondary); }
148
+ .sp-tab.active {
149
+ color: var(--sp-primary-light);
150
+ border-bottom-color: var(--sp-primary);
151
+ }
152
+
153
+ /* ══════════════════════════════════════════════
154
+ Panels
155
+ ══════════════════════════════════════════════ */
156
+ .sp-panel {
157
+ display: none;
158
+ padding: 12px 14px;
159
+ animation: spFadeIn 200ms ease;
160
+ }
161
+ .sp-panel.active { display: block; }
162
+
163
+ @keyframes spFadeIn {
164
+ from { opacity: 0; transform: translateY(4px); }
165
+ to { opacity: 1; transform: translateY(0); }
166
+ }
167
+
168
+ /* ══════════════════════════════════════════════
169
+ Input
170
+ ═════════��════════════════════════════════════ */
171
+ .sp-input-group {
172
+ margin-bottom: 10px;
173
+ }
174
+
175
+ .sp-textarea {
176
+ width: 100%;
177
+ padding: 10px 12px;
178
+ border: 1px solid var(--sp-border);
179
+ border-radius: var(--sp-radius);
180
+ background: var(--sp-surface);
181
+ color: var(--sp-text);
182
+ font-family: var(--sp-font-arabic);
183
+ font-size: 14px;
184
+ line-height: 1.7;
185
+ resize: vertical;
186
+ min-height: 80px;
187
+ transition: border-color var(--sp-transition), box-shadow var(--sp-transition);
188
+ }
189
+ .sp-textarea:focus {
190
+ outline: none;
191
+ border-color: var(--sp-primary);
192
+ box-shadow: 0 0 0 3px var(--sp-primary-glow);
193
+ }
194
+ .sp-textarea::placeholder {
195
+ color: var(--sp-text-muted);
196
+ font-size: 13px;
197
+ }
198
+
199
+ .sp-meta-row {
200
+ display: flex;
201
+ gap: 12px;
202
+ margin-top: 4px;
203
+ padding: 0 2px;
204
+ }
205
+ .sp-char-count,
206
+ .sp-word-count {
207
+ font-size: 10px;
208
+ color: var(--sp-text-muted);
209
+ }
210
+
211
+ /* ══════════════════════════════════════════════
212
+ Buttons
213
+ ══════════════════════════════════════════════ */
214
+ .sp-actions {
215
+ display: flex;
216
+ gap: 8px;
217
+ margin-bottom: 12px;
218
+ }
219
+
220
+ .sp-btn {
221
+ display: inline-flex;
222
+ align-items: center;
223
+ justify-content: center;
224
+ gap: 5px;
225
+ padding: 7px 14px;
226
+ border: none;
227
+ border-radius: var(--sp-radius-sm);
228
+ font-family: var(--sp-font-arabic);
229
+ font-size: 12px;
230
+ font-weight: 600;
231
+ cursor: pointer;
232
+ transition: all var(--sp-transition);
233
+ }
234
+
235
+ .sp-btn-primary {
236
+ flex: 1;
237
+ background: linear-gradient(135deg, var(--sp-primary), var(--sp-primary-dark));
238
+ color: white;
239
+ box-shadow: 0 2px 8px var(--sp-primary-glow);
240
+ }
241
+ .sp-btn-primary:hover {
242
+ transform: translateY(-1px);
243
+ box-shadow: 0 4px 14px var(--sp-primary-glow);
244
+ }
245
+ .sp-btn-primary:active {
246
+ transform: translateY(0);
247
+ }
248
+
249
+ .sp-btn-ghost {
250
+ background: var(--sp-surface);
251
+ color: var(--sp-text-secondary);
252
+ border: 1px solid var(--sp-border);
253
+ }
254
+ .sp-btn-ghost:hover {
255
+ background: var(--sp-surface-hover);
256
+ color: var(--sp-text);
257
+ }
258
+
259
+ .sp-btn-icon {
260
+ display: inline-flex;
261
+ align-items: center;
262
+ justify-content: center;
263
+ width: 28px;
264
+ height: 28px;
265
+ border: none;
266
+ border-radius: var(--sp-radius-sm);
267
+ background: var(--sp-surface);
268
+ color: var(--sp-text-secondary);
269
+ cursor: pointer;
270
+ transition: all var(--sp-transition);
271
+ }
272
+ .sp-btn-icon:hover {
273
+ background: var(--sp-surface-hover);
274
+ color: var(--sp-primary-light);
275
+ }
276
+
277
+ /* ══════════════════════════════════════════════
278
+ Score
279
+ ══════════════════════════════════════════════ */
280
+ .sp-score {
281
+ display: flex;
282
+ align-items: center;
283
+ gap: 12px;
284
+ padding: 12px;
285
+ margin-bottom: 12px;
286
+ background: var(--sp-surface);
287
+ border: 1px solid var(--sp-border);
288
+ border-radius: var(--sp-radius);
289
+ }
290
+
291
+ .sp-score-ring {
292
+ position: relative;
293
+ width: 64px;
294
+ height: 64px;
295
+ flex-shrink: 0;
296
+ }
297
+ .sp-score-ring svg {
298
+ width: 100%;
299
+ height: 100%;
300
+ }
301
+ .sp-score-ring circle:last-of-type {
302
+ transition: stroke-dashoffset 800ms cubic-bezier(0.4, 0, 0.2, 1);
303
+ }
304
+
305
+ .sp-score-value {
306
+ position: absolute;
307
+ inset: 0;
308
+ display: flex;
309
+ align-items: center;
310
+ justify-content: center;
311
+ font-size: 18px;
312
+ font-weight: 800;
313
+ color: var(--sp-primary-light);
314
+ }
315
+
316
+ .sp-score-details {
317
+ flex: 1;
318
+ min-width: 0;
319
+ }
320
+
321
+ .sp-score-hint {
322
+ display: block;
323
+ font-size: 11px;
324
+ color: var(--sp-text-secondary);
325
+ margin-bottom: 6px;
326
+ }
327
+
328
+ .sp-score-counts {
329
+ display: flex;
330
+ gap: 10px;
331
+ flex-wrap: wrap;
332
+ font-size: 10px;
333
+ color: var(--sp-text-muted);
334
+ }
335
+ .sp-count-spelling strong { color: var(--sp-spelling); }
336
+ .sp-count-grammar strong { color: var(--sp-grammar); }
337
+ .sp-count-punctuation strong { color: var(--sp-punctuation); }
338
+
339
+ /* ══════════════════════════════════════════════
340
+ Results
341
+ ══════════════════════════════════════════════ */
342
+ .sp-result {
343
+ margin-bottom: 12px;
344
+ border: 1px solid var(--sp-border);
345
+ border-radius: var(--sp-radius);
346
+ background: var(--sp-surface);
347
+ overflow: hidden;
348
+ }
349
+
350
+ .sp-result-header {
351
+ display: flex;
352
+ align-items: center;
353
+ justify-content: space-between;
354
+ padding: 8px 12px;
355
+ font-size: 11px;
356
+ font-weight: 700;
357
+ color: var(--sp-text-secondary);
358
+ background: var(--sp-surface-hover);
359
+ border-bottom: 1px solid var(--sp-border);
360
+ }
361
+
362
+ .sp-result-actions {
363
+ display: flex;
364
+ gap: 4px;
365
+ }
366
+
367
+ .sp-result-text {
368
+ padding: 10px 12px;
369
+ font-size: 14px;
370
+ line-height: 1.8;
371
+ color: var(--sp-text);
372
+ word-break: break-word;
373
+ }
374
+
375
+ .sp-summary-meta {
376
+ padding: 6px 12px 10px;
377
+ font-size: 10px;
378
+ color: var(--sp-text-muted);
379
+ }
380
+
381
+ /* ══════════════════════════════════════════════
382
+ Highlighted text (from bayan-renderer.js)
383
+ ══════════════════════════════════════════════ */
384
+ .bayan-spelling-error {
385
+ background: rgba(239, 68, 68, 0.15);
386
+ border-bottom: 2px solid var(--sp-spelling);
387
+ padding: 0 2px;
388
+ border-radius: 2px;
389
+ cursor: pointer;
390
+ }
391
+ .bayan-grammar-error {
392
+ background: rgba(245, 158, 11, 0.15);
393
+ border-bottom: 2px solid var(--sp-grammar);
394
+ padding: 0 2px;
395
+ border-radius: 2px;
396
+ cursor: pointer;
397
+ }
398
+ .bayan-punctuation-suggestion {
399
+ background: rgba(59, 130, 246, 0.15);
400
+ border-bottom: 2px solid var(--sp-punctuation);
401
+ padding: 0 2px;
402
+ border-radius: 2px;
403
+ cursor: pointer;
404
+ }
405
+
406
+ /* ══════════════════════════════════════════════
407
+ Suggestions
408
+ ══════════════════════════════════════════════ */
409
+ .sp-suggestions {
410
+ margin-bottom: 12px;
411
+ }
412
+
413
+ .sp-suggestions-header {
414
+ font-size: 11px;
415
+ font-weight: 700;
416
+ color: var(--sp-text-secondary);
417
+ margin-bottom: 6px;
418
+ padding: 0 2px;
419
+ }
420
+
421
+ .sp-suggestions-list {
422
+ display: flex;
423
+ flex-direction: column;
424
+ gap: 6px;
425
+ }
426
+
427
+ /* Suggestion card — reuses bayan-ui.js HTML classes */
428
+ .bayan-suggestion-card {
429
+ padding: 8px 10px;
430
+ background: var(--sp-surface);
431
+ border: 1px solid var(--sp-border);
432
+ border-radius: var(--sp-radius-sm);
433
+ transition: border-color var(--sp-transition), background var(--sp-transition);
434
+ }
435
+ .bayan-suggestion-card:hover {
436
+ border-color: var(--sp-border-light);
437
+ background: var(--sp-surface-hover);
438
+ }
439
+
440
+ .bayan-suggestion-badge {
441
+ display: inline-block;
442
+ padding: 1px 6px;
443
+ border-radius: 4px;
444
+ font-size: 9px;
445
+ font-weight: 700;
446
+ margin-bottom: 4px;
447
+ }
448
+ .bayan-badge-spelling { background: rgba(239, 68, 68, 0.15); color: var(--sp-spelling); }
449
+ .bayan-badge-grammar { background: rgba(245, 158, 11, 0.15); color: var(--sp-grammar); }
450
+ .bayan-badge-punctuation { background: rgba(59, 130, 246, 0.15); color: var(--sp-punctuation); }
451
+
452
+ .bayan-suggestion-change {
453
+ display: flex;
454
+ align-items: center;
455
+ gap: 6px;
456
+ margin-bottom: 6px;
457
+ font-size: 13px;
458
+ }
459
+
460
+ .bayan-suggestion-original {
461
+ color: var(--sp-error);
462
+ text-decoration: line-through;
463
+ text-decoration-color: rgba(239, 68, 68, 0.4);
464
+ }
465
+
466
+ .bayan-suggestion-arrow {
467
+ color: var(--sp-text-muted);
468
+ font-size: 11px;
469
+ }
470
+
471
+ .bayan-suggestion-correction {
472
+ color: var(--sp-success);
473
+ font-weight: 600;
474
+ }
475
+
476
+ .bayan-suggestion-alts {
477
+ display: flex;
478
+ flex-wrap: wrap;
479
+ gap: 4px;
480
+ }
481
+
482
+ .bayan-alt-chip {
483
+ padding: 3px 8px;
484
+ border: 1px solid var(--sp-border);
485
+ border-radius: 12px;
486
+ background: var(--sp-surface-hover);
487
+ color: var(--sp-text);
488
+ font-family: var(--sp-font-arabic);
489
+ font-size: 11px;
490
+ cursor: pointer;
491
+ transition: all var(--sp-transition);
492
+ }
493
+ .bayan-alt-chip:hover {
494
+ border-color: var(--sp-primary);
495
+ background: var(--sp-primary-glow);
496
+ }
497
+ .bayan-alt-chip--main {
498
+ border-color: var(--sp-primary);
499
+ background: rgba(99, 102, 241, 0.1);
500
+ color: var(--sp-primary-light);
501
+ }
502
+ .bayan-alt-chip--keep {
503
+ border-color: var(--sp-border);
504
+ color: var(--sp-text-muted);
505
+ font-size: 10px;
506
+ }
507
+ .bayan-alt-chip--keep:hover {
508
+ border-color: var(--sp-text-muted);
509
+ background: var(--sp-surface-active);
510
+ }
511
+
512
+ /* ══════════════════════════════════════════════
513
+ Radio group (summary length)
514
+ ══════════════════════════════════════════════ */
515
+ .sp-radio-group {
516
+ display: flex;
517
+ gap: 12px;
518
+ margin-bottom: 10px;
519
+ padding: 0 2px;
520
+ }
521
+
522
+ .sp-radio {
523
+ display: flex;
524
+ align-items: center;
525
+ gap: 4px;
526
+ font-size: 11px;
527
+ color: var(--sp-text-secondary);
528
+ cursor: pointer;
529
+ }
530
+ .sp-radio input[type="radio"] {
531
+ accent-color: var(--sp-primary);
532
+ width: 13px;
533
+ height: 13px;
534
+ }
535
+
536
+ /* ══════════════════════════════════════════════
537
+ Timing
538
+ ══════════════════════════════════════════════ */
539
+ .sp-timing {
540
+ padding: 6px 10px;
541
+ margin-bottom: 8px;
542
+ background: var(--sp-surface);
543
+ border-radius: var(--sp-radius-sm);
544
+ font-size: 10px;
545
+ color: var(--sp-text-muted);
546
+ text-align: center;
547
+ }
548
+
549
+ /* ══════════════════════════════════════════════
550
+ Loading overlay
551
+ ═══════════════════���══════════════════════════ */
552
+ .sp-loading {
553
+ position: fixed;
554
+ inset: 0;
555
+ z-index: 200;
556
+ display: flex;
557
+ flex-direction: column;
558
+ align-items: center;
559
+ justify-content: center;
560
+ gap: 12px;
561
+ background: rgba(15, 15, 20, 0.85);
562
+ backdrop-filter: blur(6px);
563
+ color: var(--sp-text);
564
+ font-size: 13px;
565
+ }
566
+
567
+ .sp-spinner {
568
+ width: 32px;
569
+ height: 32px;
570
+ border: 3px solid var(--sp-border);
571
+ border-top-color: var(--sp-primary);
572
+ border-radius: 50%;
573
+ animation: spSpin 800ms linear infinite;
574
+ }
575
+
576
+ @keyframes spSpin {
577
+ to { transform: rotate(360deg); }
578
+ }
579
+
580
+ /* ══════════════════════════════════════════════
581
+ Toast
582
+ ══════════════════════════════════════════════ */
583
+ .sp-toast {
584
+ position: fixed;
585
+ bottom: 16px;
586
+ left: 50%;
587
+ transform: translateX(-50%) translateY(60px);
588
+ padding: 8px 16px;
589
+ background: var(--sp-surface);
590
+ border: 1px solid var(--sp-border);
591
+ border-radius: 20px;
592
+ color: var(--sp-text);
593
+ font-size: 12px;
594
+ font-weight: 600;
595
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
596
+ opacity: 0;
597
+ pointer-events: none;
598
+ transition: all var(--sp-spring);
599
+ z-index: 300;
600
+ white-space: nowrap;
601
+ }
602
+ .sp-toast.is-visible {
603
+ opacity: 1;
604
+ transform: translateX(-50%) translateY(0);
605
+ }
606
+
607
+ /* ══════════════════════════════════════════════
608
+ Stale indicator
609
+ ══════════════════════════════════════════════ */
610
+ .sp-stale {
611
+ opacity: 0.45;
612
+ pointer-events: none;
613
+ position: relative;
614
+ filter: grayscale(30%);
615
+ transition: opacity var(--sp-transition), filter var(--sp-transition);
616
+ }
617
+
618
+ /* ══════════════════════════════════════════════
619
+ Utilities
620
+ ══════════════════════════════════════════════ */
621
+ .is-hidden {
622
+ display: none !important;
623
+ }
extension/sidepanel/sidepanel.html ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>بيان — لوحة التحليل</title>
7
+ <link rel="stylesheet" href="sidepanel.css">
8
+ </head>
9
+ <body>
10
+ <!-- ══════════════════════════════════════════════ -->
11
+ <!-- Header -->
12
+ <!-- ══════════════════════════════════════════════ -->
13
+ <header class="sp-header">
14
+ <div class="sp-header-brand">
15
+ <svg class="sp-logo" width="24" height="24" viewBox="0 0 100 100" fill="none">
16
+ <circle cx="50" cy="50" r="46" fill="url(#spGrad)" />
17
+ <path d="M30 55 Q35 35, 50 30 Q65 35, 70 55 Q65 65, 50 70 Q35 65, 30 55Z" fill="rgba(255,255,255,0.9)" />
18
+ <circle cx="50" cy="42" r="4" fill="url(#spGrad)" />
19
+ <defs>
20
+ <linearGradient id="spGrad" x1="0" y1="0" x2="100" y2="100">
21
+ <stop offset="0%" stop-color="#6366f1"/>
22
+ <stop offset="100%" stop-color="#8b5cf6"/>
23
+ </linearGradient>
24
+ </defs>
25
+ </svg>
26
+ <span class="sp-header-title">بيان</span>
27
+ </div>
28
+ <div class="sp-header-status" id="status-indicator">
29
+ <span class="sp-status-dot"></span>
30
+ <span class="sp-status-text" id="status-text">جاري الاتصال...</span>
31
+ </div>
32
+ </header>
33
+
34
+ <!-- ══════════════════════════════════════════════ -->
35
+ <!-- Tab Navigation -->
36
+ <!-- ══════════════════════════════════════════════ -->
37
+ <nav class="sp-tabs" role="tablist">
38
+ <button class="sp-tab active" role="tab" aria-selected="true" data-tab="correct" id="tab-correct">
39
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
40
+ تصحيح
41
+ </button>
42
+ <button class="sp-tab" role="tab" aria-selected="false" data-tab="summarize" id="tab-summarize">
43
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h10M4 18h14"/></svg>
44
+ تلخيص
45
+ </button>
46
+ </nav>
47
+
48
+ <!-- ══════════════════════════════════════════════ -->
49
+ <!-- Correct Panel -->
50
+ <!-- ══════════════════════════════════════════════ -->
51
+ <section class="sp-panel active" id="panel-correct" role="tabpanel">
52
+ <!-- Input area -->
53
+ <div class="sp-input-group">
54
+ <textarea
55
+ id="input-text"
56
+ class="sp-textarea"
57
+ dir="rtl"
58
+ placeholder="أدخل النص العربي أو اختر نصاً من أي صفحة..."
59
+ rows="4"
60
+ maxlength="5000"
61
+ ></textarea>
62
+ <div class="sp-meta-row">
63
+ <span class="sp-char-count"><span id="char-count">٠</span> حرف</span>
64
+ <span class="sp-word-count"><span id="word-count">٠</span> كلمة</span>
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Action buttons -->
69
+ <div class="sp-actions">
70
+ <button class="sp-btn sp-btn-primary" id="btn-correct">
71
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"/></svg>
72
+ تحليل وتصحيح
73
+ </button>
74
+ <button class="sp-btn sp-btn-ghost" id="btn-clear">مسح</button>
75
+ </div>
76
+
77
+ <!-- Score ring -->
78
+ <div class="sp-score is-hidden" id="score-section">
79
+ <div class="sp-score-ring">
80
+ <svg viewBox="0 0 160 160">
81
+ <circle cx="80" cy="80" r="70" fill="none" stroke="var(--sp-border)" stroke-width="8" />
82
+ <circle cx="80" cy="80" r="70" fill="none" stroke="var(--sp-primary)" stroke-width="8"
83
+ stroke-dasharray="440" stroke-dashoffset="440" stroke-linecap="round"
84
+ id="score-circle" transform="rotate(-90 80 80)" />
85
+ </svg>
86
+ <span class="sp-score-value" id="score-value">--</span>
87
+ </div>
88
+ <div class="sp-score-details">
89
+ <span class="sp-score-hint" id="score-hint"></span>
90
+ <div class="sp-score-counts">
91
+ <span class="sp-count-spelling">إملائي: <strong id="count-spelling">٠</strong></span>
92
+ <span class="sp-count-grammar">نحوي: <strong id="count-grammar">٠</strong></span>
93
+ <span class="sp-count-punctuation">ترقيم: <strong id="count-punctuation">٠</strong></span>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- Corrected text result -->
99
+ <div class="sp-result is-hidden" id="result-section">
100
+ <div class="sp-result-header">
101
+ <span>النص المصحح</span>
102
+ <div class="sp-result-actions">
103
+ <button class="sp-btn-icon" id="btn-apply-all" title="تطبيق الكل">
104
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
105
+ </button>
106
+ <button class="sp-btn-icon" id="btn-copy-result" title="نسخ">
107
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
108
+ </button>
109
+ </div>
110
+ </div>
111
+ <div class="sp-result-text" id="result-text" dir="rtl"></div>
112
+ </div>
113
+
114
+ <!-- Suggestions list -->
115
+ <div class="sp-suggestions is-hidden" id="suggestions-section">
116
+ <div class="sp-suggestions-header">الاقتراحات</div>
117
+ <div class="sp-suggestions-list" id="suggestions-list" role="list"></div>
118
+ </div>
119
+
120
+ <!-- Timing info -->
121
+ <div class="sp-timing is-hidden" id="timing-section">
122
+ <span id="timing-text"></span>
123
+ </div>
124
+ </section>
125
+
126
+ <!-- ══════════════════════════════════════════════ -->
127
+ <!-- Summarize Panel -->
128
+ <!-- ══════════════════════════════════════════════ -->
129
+ <section class="sp-panel" id="panel-summarize" role="tabpanel">
130
+ <div class="sp-input-group">
131
+ <textarea
132
+ id="summary-input-text"
133
+ class="sp-textarea"
134
+ dir="rtl"
135
+ placeholder="الصق النص المراد تلخيصه..."
136
+ rows="4"
137
+ maxlength="5000"
138
+ ></textarea>
139
+ <div class="sp-meta-row">
140
+ <span class="sp-char-count"><span id="summary-char-count">٠</span> حرف</span>
141
+ </div>
142
+ </div>
143
+
144
+ <!-- Summary length options -->
145
+ <div class="sp-radio-group">
146
+ <label class="sp-radio"><input type="radio" name="summary-length" value="1"> قصير</label>
147
+ <label class="sp-radio"><input type="radio" name="summary-length" value="2" checked> متوسط</label>
148
+ <label class="sp-radio"><input type="radio" name="summary-length" value="3"> طويل</label>
149
+ </div>
150
+
151
+ <div class="sp-actions">
152
+ <button class="sp-btn sp-btn-primary" id="btn-summarize">
153
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h10M4 18h14"/></svg>
154
+ تلخيص
155
+ </button>
156
+ </div>
157
+
158
+ <!-- Summary result -->
159
+ <div class="sp-result is-hidden" id="summary-result-section">
160
+ <div class="sp-result-header">
161
+ <span>الملخص</span>
162
+ <button class="sp-btn-icon" id="btn-copy-summary" title="نسخ">
163
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
164
+ </button>
165
+ </div>
166
+ <div class="sp-result-text" id="summary-text" dir="rtl"></div>
167
+ <div class="sp-summary-meta" id="summary-meta"></div>
168
+ </div>
169
+ </section>
170
+
171
+ <!-- ══════════════════════════════════════════════ -->
172
+ <!-- Loading Overlay -->
173
+ <!-- ══════════════════════════════════════════════ -->
174
+ <div class="sp-loading is-hidden" id="loading-overlay">
175
+ <div class="sp-spinner"></div>
176
+ <span id="loading-text">جارٍ التحليل...</span>
177
+ </div>
178
+
179
+ <!-- Toast -->
180
+ <div class="sp-toast" id="toast"></div>
181
+
182
+ <!-- ══════════════════════════════════════════════ -->
183
+ <!-- Scripts — reuse ALL shared modules -->
184
+ <!-- ══════════════════════════════════════════════ -->
185
+ <script src="../shared/constants.js"></script>
186
+ <script src="../shared/config.js"></script>
187
+ <script src="../shared/bayan-renderer.js"></script>
188
+ <script src="../shared/bayan-ui.js"></script>
189
+ <script src="../shared/bayan-patches.js"></script>
190
+ <script src="../shared/bayan-api.js"></script>
191
+ <script src="sidepanel.js"></script>
192
+ </body>
193
+ </html>
extension/sidepanel/sidepanel.js ADDED
@@ -0,0 +1,532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — Side Panel Logic
3
+ *
4
+ * Phase 5: Persistent workspace panel
5
+ *
6
+ * Reuses:
7
+ * - bayanAnalyze(), bayanSummarize(), bayanHealthCheck() from bayan-api.js
8
+ * - renderHighlightedText(), escapeHtml() from bayan-renderer.js
9
+ * - buildSuggestionCardHTML(), calculateWritingScore(), getScoreHint() from bayan-ui.js
10
+ * - applyAndRebase(), applyAllPatches(), sortSuggestions(), countByType(), removeSuggestion() from bayan-patches.js
11
+ *
12
+ * Key differences from popup.js:
13
+ * - Persistent: panel stays open across page navigations
14
+ * - Auto-analysis: text injected from context menu auto-analyzes
15
+ * - Debounced live updates: re-analyzes on user edits (500ms debounce)
16
+ * - State persistence: last analysis saved to chrome.storage.session
17
+ */
18
+
19
+ document.addEventListener('DOMContentLoaded', () => {
20
+ // ── Tab constants ──
21
+ const TAB = { CORRECT: 'correct', SUMMARIZE: 'summarize' };
22
+
23
+ // ── Element references ──
24
+ const inputText = document.getElementById('input-text');
25
+ const charCount = document.getElementById('char-count');
26
+ const wordCount = document.getElementById('word-count');
27
+ const btnCorrect = document.getElementById('btn-correct');
28
+ const btnClear = document.getElementById('btn-clear');
29
+ const btnApplyAll = document.getElementById('btn-apply-all');
30
+ const btnCopyResult = document.getElementById('btn-copy-result');
31
+ const scoreSection = document.getElementById('score-section');
32
+ const resultSection = document.getElementById('result-section');
33
+ const resultText = document.getElementById('result-text');
34
+ const suggestionsSection = document.getElementById('suggestions-section');
35
+ const suggestionsList = document.getElementById('suggestions-list');
36
+ const timingSection = document.getElementById('timing-section');
37
+ const timingText = document.getElementById('timing-text');
38
+ const loadingOverlay = document.getElementById('loading-overlay');
39
+ const loadingTextEl = document.getElementById('loading-text');
40
+
41
+ // Summary tab
42
+ const summaryInputText = document.getElementById('summary-input-text');
43
+ const summaryCharCount = document.getElementById('summary-char-count');
44
+ const btnSummarize = document.getElementById('btn-summarize');
45
+ const summaryResultSection = document.getElementById('summary-result-section');
46
+ const summaryText = document.getElementById('summary-text');
47
+ const summaryMeta = document.getElementById('summary-meta');
48
+ const btnCopySummary = document.getElementById('btn-copy-summary');
49
+
50
+ // Score
51
+ const scoreValue = document.getElementById('score-value');
52
+ const scoreCircle = document.getElementById('score-circle');
53
+ const scoreHint = document.getElementById('score-hint');
54
+ const countSpelling = document.getElementById('count-spelling');
55
+ const countGrammar = document.getElementById('count-grammar');
56
+ const countPunctuation = document.getElementById('count-punctuation');
57
+
58
+ // ══════════════════════════════════════════════════════════
59
+ // State
60
+ // ══════════════════════════════════════════════════════════
61
+ let currentSuggestions = [];
62
+ let analyzedText = '';
63
+ let isStale = false;
64
+ let isAnalyzing = false;
65
+ let contextConsumed = false;
66
+ let debounceTimer = null;
67
+
68
+ const SCORE_CIRCUMFERENCE = 440;
69
+ const DEBOUNCE_MS = 500;
70
+
71
+ // ══════════════════════════════════════════════════════════
72
+ // Tab switching
73
+ // ══════════════════════════════════════════════════════════
74
+ document.querySelectorAll('.sp-tab').forEach((tab) => {
75
+ tab.addEventListener('click', () => {
76
+ const targetTab = tab.dataset.tab;
77
+ document.querySelectorAll('.sp-tab').forEach((t) => {
78
+ t.classList.toggle('active', t.dataset.tab === targetTab);
79
+ t.setAttribute('aria-selected', t.dataset.tab === targetTab ? 'true' : 'false');
80
+ });
81
+ document.querySelectorAll('.sp-panel').forEach((p) => {
82
+ p.classList.toggle('active', p.id === `panel-${targetTab}`);
83
+ });
84
+ });
85
+ });
86
+
87
+ // ══════════════════════════════════════════════════════════
88
+ // Character & word counter
89
+ // ══════════════════════════════════════════════════════════
90
+ function updateCounts(textarea, charEl, wordEl) {
91
+ const text = textarea.value;
92
+ const chars = text.length;
93
+ const words = text.trim() ? text.trim().split(/\s+/).length : 0;
94
+ if (charEl) charEl.textContent = chars.toLocaleString('ar-EG');
95
+ if (wordEl) wordEl.textContent = words.toLocaleString('ar-EG');
96
+ }
97
+
98
+ inputText.addEventListener('input', () => {
99
+ updateCounts(inputText, charCount, wordCount);
100
+
101
+ // Staleness detection
102
+ if (currentSuggestions.length > 0 && inputText.value !== analyzedText) {
103
+ markStale();
104
+ }
105
+
106
+ // Debounced live re-analysis
107
+ if (debounceTimer) clearTimeout(debounceTimer);
108
+ debounceTimer = setTimeout(() => {
109
+ const text = inputText.value.trim();
110
+ if (text.length >= CONFIG.MIN_ANALYZE_LENGTH && !isAnalyzing) {
111
+ runAnalysis(text);
112
+ }
113
+ }, DEBOUNCE_MS);
114
+ });
115
+
116
+ summaryInputText.addEventListener('input', () => {
117
+ updateCounts(summaryInputText, summaryCharCount, null);
118
+ });
119
+
120
+ // ══════════════════════════════════════════════════════════
121
+ // Staleness management
122
+ // ══════════════════════════════════════════════════════════
123
+ function markStale() {
124
+ if (isStale) return;
125
+ isStale = true;
126
+ if (resultSection) resultSection.classList.add('sp-stale');
127
+ if (suggestionsSection) suggestionsSection.classList.add('sp-stale');
128
+ }
129
+
130
+ function clearStale() {
131
+ isStale = false;
132
+ if (resultSection) resultSection.classList.remove('sp-stale');
133
+ if (suggestionsSection) suggestionsSection.classList.remove('sp-stale');
134
+ }
135
+
136
+ // ══════════════════════════════════════════════════════════
137
+ // Loading & Toast
138
+ // ══════════════════════════════════════════════════════════
139
+ function setLoading(show, text = 'جارٍ التحليل...') {
140
+ loadingOverlay.classList.toggle('is-hidden', !show);
141
+ loadingTextEl.textContent = text;
142
+ }
143
+
144
+ function showToast(message, duration = 2500) {
145
+ const toast = document.getElementById('toast');
146
+ toast.textContent = message;
147
+ toast.classList.add('is-visible');
148
+ clearTimeout(toast._timer);
149
+ toast._timer = setTimeout(() => toast.classList.remove('is-visible'), duration);
150
+ }
151
+
152
+ // ══════════════════════════════════════════════════════════
153
+ // Score ring
154
+ // ══════════════════════════════════════════════════════════
155
+ function updateScore(spelling, grammar, punctuation) {
156
+ const score = calculateWritingScore(spelling, grammar, punctuation);
157
+ const total = spelling + grammar + punctuation;
158
+
159
+ scoreSection.classList.remove('is-hidden');
160
+
161
+ if (scoreValue) scoreValue.textContent = score > 0 || total > 0 ? score.toLocaleString('ar-EG') : '--';
162
+ if (scoreCircle) {
163
+ const offset = SCORE_CIRCUMFERENCE - (score / 100) * SCORE_CIRCUMFERENCE;
164
+ scoreCircle.style.strokeDashoffset = String(offset);
165
+ }
166
+ if (scoreHint) scoreHint.textContent = getScoreHint(score, total);
167
+ if (countSpelling) countSpelling.textContent = spelling.toLocaleString('ar-EG');
168
+ if (countGrammar) countGrammar.textContent = grammar.toLocaleString('ar-EG');
169
+ if (countPunctuation) countPunctuation.textContent = punctuation.toLocaleString('ar-EG');
170
+ }
171
+
172
+ // ══════════════════════════════════════════════════════════
173
+ // Render suggestions list
174
+ // ══════════════════════════════════════════════════════════
175
+ function renderSuggestions(suggestions) {
176
+ currentSuggestions = suggestions;
177
+
178
+ if (!suggestions || suggestions.length === 0) {
179
+ suggestionsSection.classList.add('is-hidden');
180
+ btnApplyAll.classList.add('is-hidden');
181
+ return;
182
+ }
183
+
184
+ suggestionsSection.classList.remove('is-hidden');
185
+ suggestionsList.innerHTML = suggestions.map((s, i) => buildSuggestionCardHTML(s, i)).join('');
186
+
187
+ // Bind alt-chip click events
188
+ suggestionsList.querySelectorAll('.bayan-alt-chip').forEach((chip) => {
189
+ chip.addEventListener('click', (e) => {
190
+ e.stopPropagation();
191
+
192
+ if (isStale) {
193
+ showToast('⚠ أعد التحليل أولاً — النص تغيّر');
194
+ return;
195
+ }
196
+
197
+ const suggestionId = chip.dataset.cardId;
198
+ const altText = chip.dataset.cardAlt;
199
+ const suggestion = currentSuggestions.find((s) => String(s.id || '') === String(suggestionId));
200
+ if (!suggestion) return;
201
+
202
+ if (altText === suggestion.original) {
203
+ currentSuggestions = removeSuggestion(currentSuggestions, suggestion.id);
204
+ } else {
205
+ const result = applyAndRebase(analyzedText, suggestion, altText, currentSuggestions);
206
+ analyzedText = result.text;
207
+ currentSuggestions = result.suggestions;
208
+ inputText.value = analyzedText;
209
+ updateCounts(inputText, charCount, wordCount);
210
+ }
211
+
212
+ const counts = countByType(currentSuggestions);
213
+ updateScore(counts.spelling, counts.grammar, counts.punctuation);
214
+ renderSuggestions(currentSuggestions);
215
+ resultText.innerHTML = renderHighlightedText(analyzedText, currentSuggestions);
216
+ saveState();
217
+ showToast('✓ تم التصحيح');
218
+ });
219
+ });
220
+
221
+ btnApplyAll.classList.toggle('is-hidden', suggestions.length < 2);
222
+ }
223
+
224
+ // ══════════════════════════════════════════════════════════
225
+ // Core analysis function
226
+ // ══════════════════════════════════════════════════════════
227
+ async function runAnalysis(text) {
228
+ if (!text || text.length < CONFIG.MIN_ANALYZE_LENGTH) {
229
+ showToast('النص قصير جداً (الحد الأدنى ١٥ حرفاً)');
230
+ return;
231
+ }
232
+ if (text.length > CONFIG.MAX_ANALYZE_LENGTH) {
233
+ showToast('النص طويل جداً (الحد الأقصى ٥٠٠٠ حرف)');
234
+ return;
235
+ }
236
+
237
+ isAnalyzing = true;
238
+ setLoading(true, 'جارٍ التحليل...');
239
+ clearStale();
240
+
241
+ try {
242
+ const data = await bayanAnalyze(text);
243
+
244
+ if (data.status === 'success' || data.status === 'partial') {
245
+ const suggestions = sortSuggestions(data.suggestions || []);
246
+ currentSuggestions = suggestions;
247
+ analyzedText = data.original;
248
+
249
+ inputText.value = analyzedText;
250
+ updateCounts(inputText, charCount, wordCount);
251
+
252
+ resultSection.classList.remove('is-hidden');
253
+ resultText.innerHTML = renderHighlightedText(analyzedText, suggestions);
254
+
255
+ const counts = countByType(suggestions);
256
+ updateScore(counts.spelling, counts.grammar, counts.punctuation);
257
+ renderSuggestions(suggestions);
258
+
259
+ if (data.timing_ms) {
260
+ timingSection.classList.remove('is-hidden');
261
+ timingText.textContent = `التحليل: ${data.timing_ms.total_ms || 0}ms`;
262
+ }
263
+
264
+ saveState();
265
+
266
+ if (suggestions.length === 0) {
267
+ showToast('نصك ممتاز! لم نجد أي أخطاء ✨');
268
+ }
269
+ } else {
270
+ showToast('تعذّر التحليل — حاول مرة أخرى');
271
+ }
272
+ } catch (error) {
273
+ console.error('[Bayan SP] Analysis error:', error);
274
+ showToast('خطأ في الاتصال — تحقق من الإنترنت');
275
+ } finally {
276
+ isAnalyzing = false;
277
+ setLoading(false);
278
+ }
279
+ }
280
+
281
+ // ══════════════════════════════════════════════════════════
282
+ // State persistence (side panel survives navigation)
283
+ // ══════════════════════════════════════════════════════════
284
+ function getStorage() {
285
+ return chrome.storage?.session || chrome.storage?.local;
286
+ }
287
+
288
+ function saveState() {
289
+ const storage = getStorage();
290
+ if (!storage) return;
291
+ storage.set({
292
+ spLastText: analyzedText,
293
+ spLastSuggestions: currentSuggestions,
294
+ spLastInput: inputText.value,
295
+ }).catch(() => {});
296
+ }
297
+
298
+ async function restoreState() {
299
+ const storage = getStorage();
300
+ if (!storage) return false;
301
+
302
+ try {
303
+ const data = await storage.get(['spLastText', 'spLastSuggestions', 'spLastInput']);
304
+ if (!data.spLastText || !data.spLastSuggestions) return false;
305
+
306
+ analyzedText = data.spLastText;
307
+ currentSuggestions = data.spLastSuggestions;
308
+ inputText.value = data.spLastInput || analyzedText;
309
+ updateCounts(inputText, charCount, wordCount);
310
+
311
+ resultSection.classList.remove('is-hidden');
312
+ resultText.innerHTML = renderHighlightedText(analyzedText, currentSuggestions);
313
+
314
+ const counts = countByType(currentSuggestions);
315
+ updateScore(counts.spelling, counts.grammar, counts.punctuation);
316
+ renderSuggestions(currentSuggestions);
317
+
318
+ return true;
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
324
+ // ══════════════════════════════════════════════════════════
325
+ // Button handlers
326
+ // ═══════════════════════════════════════════════════════���══
327
+ btnCorrect.addEventListener('click', () => {
328
+ const text = inputText.value.trim();
329
+ runAnalysis(text);
330
+ });
331
+
332
+ btnClear.addEventListener('click', () => {
333
+ inputText.value = '';
334
+ analyzedText = '';
335
+ updateCounts(inputText, charCount, wordCount);
336
+ scoreSection.classList.add('is-hidden');
337
+ resultSection.classList.add('is-hidden');
338
+ suggestionsSection.classList.add('is-hidden');
339
+ timingSection.classList.add('is-hidden');
340
+ currentSuggestions = [];
341
+ clearStale();
342
+ const storage = getStorage();
343
+ if (storage) storage.remove(['spLastText', 'spLastSuggestions', 'spLastInput']).catch(() => {});
344
+ });
345
+
346
+ btnApplyAll.addEventListener('click', () => {
347
+ if (currentSuggestions.length === 0) return;
348
+ if (isStale) {
349
+ showToast('⚠ أعد التحليل أولاً — النص تغيّر');
350
+ return;
351
+ }
352
+
353
+ analyzedText = applyAllPatches(analyzedText, currentSuggestions);
354
+ inputText.value = analyzedText;
355
+ updateCounts(inputText, charCount, wordCount);
356
+ currentSuggestions = [];
357
+ resultText.innerHTML = escapeHtml(analyzedText);
358
+ updateScore(0, 0, 0);
359
+ renderSuggestions([]);
360
+ saveState();
361
+ showToast('✓ تم تطبيق جميع التصحيحات');
362
+ });
363
+
364
+ btnCopyResult.addEventListener('click', () => {
365
+ const text = resultText.textContent || '';
366
+ navigator.clipboard.writeText(text)
367
+ .then(() => showToast('✓ تم نسخ النص'))
368
+ .catch(() => showToast('تعذّر النسخ'));
369
+ });
370
+
371
+ // ══════════════════════════════════════════════════════════
372
+ // Summarize
373
+ // ══════════════════════════════════════════════════════════
374
+ btnSummarize.addEventListener('click', async () => {
375
+ const text = summaryInputText.value.trim();
376
+ if (!text) { showToast('أدخل نصاً للتلخيص'); return; }
377
+ if (text.length < CONFIG.MIN_SUMMARIZE_LENGTH) { showToast('النص قصير جداً للتلخيص'); return; }
378
+
379
+ const lengthValue = parseInt(document.querySelector('input[name="summary-length"]:checked')?.value || '2', 10);
380
+ setLoading(true, 'جارٍ التلخيص...');
381
+
382
+ try {
383
+ const data = await bayanSummarize(text, lengthValue);
384
+ if (data.status === 'success' && data.summary) {
385
+ summaryResultSection.classList.remove('is-hidden');
386
+ summaryText.textContent = data.summary;
387
+ summaryMeta.textContent = `النص الأصلي: ${(data.original_length || 0).toLocaleString('ar-EG')} حرف → الملخص: ${(data.summary_length || 0).toLocaleString('ar-EG')} حرف`;
388
+ showToast('✓ تم التلخيص');
389
+ } else {
390
+ showToast('تعذّر التلخيص — حاول مرة أخرى');
391
+ }
392
+ } catch (error) {
393
+ console.error('[Bayan SP] Summarization error:', error);
394
+ showToast('خطأ في الاتصال — تحقق من الإنترنت');
395
+ } finally {
396
+ setLoading(false);
397
+ }
398
+ });
399
+
400
+ btnCopySummary.addEventListener('click', () => {
401
+ const text = summaryText.textContent || '';
402
+ navigator.clipboard.writeText(text)
403
+ .then(() => showToast('✓ تم نسخ الملخص'))
404
+ .catch(() => showToast('تعذّر النسخ'));
405
+ });
406
+
407
+ // ══════════════════════════════════════════════════════════
408
+ // Status check
409
+ // ══════════════════════════════════════════════════════════
410
+ (async function checkStatus() {
411
+ const statusDot = document.querySelector('.sp-status-dot');
412
+ const statusText = document.getElementById('status-text');
413
+ try {
414
+ await bayanHealthCheck();
415
+ if (statusDot) statusDot.classList.add('online');
416
+ if (statusText) statusText.textContent = 'متصل';
417
+ } catch {
418
+ if (statusDot) statusDot.classList.add('offline');
419
+ if (statusText) statusText.textContent = 'غير متصل';
420
+ }
421
+ })();
422
+
423
+ // ══════════════════════════════════════════════════════════
424
+ // Context menu pickup (from background.js → storage)
425
+ // NOTE: background.js calls sidePanel.open() BEFORE storage.set()
426
+ // to preserve the user gesture token. This means on first open,
427
+ // storage may not be ready yet. We retry once after 300ms.
428
+ // If the panel is ALREADY open, the storage.onChanged listener
429
+ // below catches new actions in real-time.
430
+ // ══════════════════════════════════════════════════════════
431
+ async function tryPickupContext(retryCount = 0) {
432
+ if (typeof chrome === 'undefined' || !chrome.storage) return;
433
+ if (contextConsumed) return;
434
+
435
+ const storage = getStorage();
436
+ if (!storage) return;
437
+
438
+ try {
439
+ const data = await storage.get(['contextAction', 'contextText', 'contextTimestamp']);
440
+
441
+ // Storage not ready yet — retry once after 300ms
442
+ if ((!data.contextAction || !data.contextText) && retryCount < 2) {
443
+ setTimeout(() => tryPickupContext(retryCount + 1), 300);
444
+ return;
445
+ }
446
+
447
+ if (!data.contextAction || !data.contextText) {
448
+ // No context action after retries — restore previous state
449
+ await restoreState();
450
+ return;
451
+ }
452
+
453
+ const age = Date.now() - (data.contextTimestamp || 0);
454
+ if (age > 15000) {
455
+ chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
456
+ await restoreState();
457
+ return;
458
+ }
459
+
460
+ contextConsumed = true;
461
+
462
+ console.log(`[Bayan SP] Context action: ${data.contextAction}, text: ${data.contextText.length} chars`);
463
+
464
+ if (data.contextAction === TAB.CORRECT) {
465
+ inputText.value = data.contextText;
466
+ updateCounts(inputText, charCount, wordCount);
467
+
468
+ const correctTab = document.querySelector(`[data-tab="${TAB.CORRECT}"]`);
469
+ if (correctTab) correctTab.click();
470
+
471
+ // Auto-analyze immediately
472
+ setTimeout(() => runAnalysis(data.contextText), 100);
473
+
474
+ } else if (data.contextAction === TAB.SUMMARIZE) {
475
+ summaryInputText.value = data.contextText;
476
+ updateCounts(summaryInputText, summaryCharCount, null);
477
+
478
+ const summarizeTab = document.querySelector(`[data-tab="${TAB.SUMMARIZE}"]`);
479
+ if (summarizeTab) summarizeTab.click();
480
+
481
+ setTimeout(() => btnSummarize.click(), 100);
482
+ }
483
+
484
+ chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
485
+
486
+ } catch (err) {
487
+ console.warn('[Bayan SP] Context action check failed:', err);
488
+ await restoreState();
489
+ }
490
+ }
491
+
492
+ // Start context pickup with retry
493
+ tryPickupContext(0);
494
+
495
+ // ══════════════════════════════════════════════════════════
496
+ // Listen for NEW context actions while panel is already open
497
+ // ══════════════════════════════════════════════════════════
498
+ if (chrome.storage?.session?.onChanged || chrome.storage?.onChanged) {
499
+ const storageApi = chrome.storage;
500
+ storageApi.onChanged.addListener((changes, area) => {
501
+ if (area !== 'session' && area !== 'local') return;
502
+ if (!changes.contextAction || !changes.contextText) return;
503
+
504
+ const action = changes.contextAction.newValue;
505
+ const text = changes.contextText.newValue;
506
+ if (!action || !text) return;
507
+
508
+ console.log(`[Bayan SP] Storage changed — new context: ${action}, ${text.length} chars`);
509
+
510
+ if (action === TAB.CORRECT) {
511
+ inputText.value = text;
512
+ updateCounts(inputText, charCount, wordCount);
513
+
514
+ const correctTab = document.querySelector(`[data-tab="${TAB.CORRECT}"]`);
515
+ if (correctTab) correctTab.click();
516
+
517
+ runAnalysis(text);
518
+
519
+ } else if (action === TAB.SUMMARIZE) {
520
+ summaryInputText.value = text;
521
+ updateCounts(summaryInputText, summaryCharCount, null);
522
+
523
+ const summarizeTab = document.querySelector(`[data-tab="${TAB.SUMMARIZE}"]`);
524
+ if (summarizeTab) summarizeTab.click();
525
+
526
+ setTimeout(() => btnSummarize.click(), 100);
527
+ }
528
+
529
+ chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
530
+ });
531
+ }
532
+ });
extension/tests/api_response_tc2.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "corrected": "أنا ذاهب الي البت.",
3
+ "original": "انا ذاهب الي البت",
4
+ "status": "success",
5
+ "suggestions": [
6
+ {
7
+ "alternatives": [
8
+
9
+ ],
10
+ "confidence": 0.8,
11
+ "correction": "البت.",
12
+ "end": 17,
13
+ "id": "bad18f97-041d-4487-a780-17fc4082447d",
14
+ "locked": true,
15
+ "original": "البت",
16
+ "priority": 2,
17
+ "start": 13,
18
+ "type": "punctuation"
19
+ },
20
+ {
21
+ "alternatives": [
22
+
23
+ ],
24
+ "confidence": 1.0,
25
+ "correction": "أنا",
26
+ "end": 3,
27
+ "id": "1c3d902b-11a9-4350-86e2-a74e66bb0639",
28
+ "locked": true,
29
+ "original": "انا",
30
+ "priority": 1,
31
+ "start": 0,
32
+ "type": "spelling"
33
+ }
34
+ ],
35
+ "timing_ms": {
36
+ "grammar_ms": 585,
37
+ "punctuation_ms": 368,
38
+ "spelling_ms": 2585,
39
+ "total_ms": 3540
40
+ }
41
+ }
extension/tests/debug_offsets.ps1 ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ $t = 'أنا ذاهب الى البت والمدرسه'
2
+ Write-Host "Length: $($t.Length)"
3
+ Write-Host "Chars 10-13: '$($t.Substring(10, 3))'"
4
+ Write-Host "Chars 14-17: '$($t.Substring(14, 3))'"
5
+ Write-Host "Chars 19-26: '$($t.Substring(19, 7))'"
6
+
7
+ # Direct test: substring replacement
8
+ $before = $t.Substring(0, 10)
9
+ $after = $t.Substring(13)
10
+ $result = $before + 'إلى' + $after
11
+ Write-Host "`nReplace [10:13] 'الى' with 'إلى':"
12
+ Write-Host "Result: '$result'"
13
+ Write-Host "Expected: 'أنا ذاهب إلى البت والمدرسه'"
14
+ Write-Host "Match: $($result -eq 'أنا ذاهب إلى البت والمدرسه')"
extension/tests/test_api_real.ps1 ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ $ErrorActionPreference = 'Stop'
2
+
3
+ # Test Case 1: Basic Correction Flow
4
+ Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
5
+ Write-Host "TEST CASE 1: Basic Correction Flow" -ForegroundColor Cyan
6
+ Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
7
+
8
+ $inputText = 'انا ذهبت الي المدرسه اليوم'
9
+ Write-Host "`nInput: `"$inputText`""
10
+
11
+ $body = @{ text = $inputText } | ConvertTo-Json -Compress
12
+ $response = Invoke-RestMethod -Uri 'https://bayan10-bayan-api.hf.space/api/analyze' -Method POST -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -ContentType 'application/json; charset=utf-8'
13
+
14
+ Write-Host "`n── API Response ──" -ForegroundColor Yellow
15
+ Write-Host "Status: $($response.status)"
16
+ Write-Host "Original: `"$($response.original)`""
17
+ Write-Host "Corrected: `"$($response.corrected)`""
18
+ Write-Host "Suggestions count: $($response.suggestions.Count)"
19
+
20
+ Write-Host "`n── Timing ──" -ForegroundColor Yellow
21
+ Write-Host "Total: $($response.timing_ms.total_ms)ms"
22
+ Write-Host "Spelling: $($response.timing_ms.spelling_ms)ms"
23
+ Write-Host "Grammar: $($response.timing_ms.grammar_ms)ms"
24
+ Write-Host "Punctuation: $($response.timing_ms.punctuation_ms)ms"
25
+
26
+ Write-Host "`n── Suggestions ──" -ForegroundColor Yellow
27
+ foreach ($s in $response.suggestions) {
28
+ Write-Host " [$($s.type)] `"$($s.original)`" → `"$($s.correction)`" (start:$($s.start), end:$($s.end), id:$($s.id.Substring(0,8))...)"
29
+ if ($s.alternatives) {
30
+ Write-Host " Alternatives: $($s.alternatives -join ', ')"
31
+ }
32
+ }
33
+
34
+ # Schema validation
35
+ Write-Host "`n── Schema Validation ──" -ForegroundColor Yellow
36
+ $fields = @('status', 'original', 'corrected', 'suggestions', 'timing_ms')
37
+ foreach ($f in $fields) {
38
+ $exists = $null -ne $response.$f
39
+ $icon = if ($exists) { "✅" } else { "❌" }
40
+ Write-Host " $icon Field '$f' present"
41
+ }
42
+
43
+ # Suggestion schema
44
+ if ($response.suggestions.Count -gt 0) {
45
+ $s = $response.suggestions[0]
46
+ $sFields = @('id', 'start', 'end', 'original', 'correction', 'type', 'alternatives')
47
+ foreach ($f in $sFields) {
48
+ $exists = $null -ne $s.$f
49
+ $icon = if ($exists) { "✅" } else { "❌" }
50
+ Write-Host " $icon Suggestion field '$f' present"
51
+ }
52
+ }
53
+
54
+ Write-Host "`n═══════════════════════════════════════════════════" -ForegroundColor Cyan
55
+ Write-Host "TEST CASE 2: Sequential Apply Input" -ForegroundColor Cyan
56
+ Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
57
+
58
+ $inputText2 = 'انا ذاهب الي البت'
59
+ Write-Host "`nInput: `"$inputText2`""
60
+
61
+ $body2 = @{ text = $inputText2 } | ConvertTo-Json -Compress
62
+ $response2 = Invoke-RestMethod -Uri 'https://bayan10-bayan-api.hf.space/api/analyze' -Method POST -Body ([System.Text.Encoding]::UTF8.GetBytes($body2)) -ContentType 'application/json; charset=utf-8'
63
+
64
+ Write-Host "`n── API Response ──" -ForegroundColor Yellow
65
+ Write-Host "Status: $($response2.status)"
66
+ Write-Host "Original: `"$($response2.original)`""
67
+ Write-Host "Corrected: `"$($response2.corrected)`""
68
+ Write-Host "Suggestions count: $($response2.suggestions.Count)"
69
+
70
+ Write-Host "`n── Suggestions ──" -ForegroundColor Yellow
71
+ foreach ($s in $response2.suggestions) {
72
+ Write-Host " [$($s.type)] `"$($s.original)`" → `"$($s.correction)`" (start:$($s.start), end:$($s.end))"
73
+ if ($s.alternatives) {
74
+ Write-Host " Alternatives: $($s.alternatives -join ', ')"
75
+ }
76
+ }
77
+
78
+ # Save response for browser test
79
+ $response2 | ConvertTo-Json -Depth 5 | Out-File -FilePath 'e:\Atef''s Shit\extension\tests\api_response_tc2.json' -Encoding UTF8
80
+ Write-Host "`n✅ Response saved to api_response_tc2.json"
extension/tests/test_e2e_real.html ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Bayan — E2E Verification</title>
6
+ <style>
7
+ body { font-family: 'Segoe UI', Tahoma, sans-serif; background: #0f0f14; color: #f0f0f5; padding: 24px; max-width: 800px; margin: 0 auto; }
8
+ h1 { color: #818cf8; font-size: 20px; margin-bottom: 20px; }
9
+ h2 { color: #9898ad; font-size: 16px; margin: 20px 0 10px; border-bottom: 1px solid #2d2d3d; padding-bottom: 6px; }
10
+ .test-group { margin-bottom: 16px; padding: 12px; background: #1a1a24; border-radius: 8px; border: 1px solid #2d2d3d; }
11
+ .pass { color: #22c55e; }
12
+ .fail { color: #ef4444; font-weight: bold; }
13
+ .result-line { font-size: 13px; margin: 4px 0; font-family: monospace; direction: ltr; text-align: left; }
14
+ .detail { color: #6b6b80; font-size: 12px; margin-left: 24px; direction: ltr; text-align: left; }
15
+ .summary { margin-top: 24px; padding: 16px; background: #1a1a24; border-radius: 8px; border: 2px solid #818cf8; font-size: 16px; font-weight: bold; direction: ltr; text-align: center; }
16
+ #log { white-space: pre-wrap; font-family: monospace; font-size: 12px; color: #9898ad; background: #111118; padding: 12px; border-radius: 8px; margin-top: 12px; direction: ltr; text-align: left; max-height: 300px; overflow-y: auto; }
17
+ .arabic-text { direction: rtl; text-align: right; font-size: 16px; padding: 8px; background: #22222e; border-radius: 6px; margin: 6px 0; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <h1>🔬 Bayan — Real E2E Verification</h1>
22
+ <div id="output"></div>
23
+ <h2>Execution Log</h2>
24
+ <div id="log"></div>
25
+
26
+ <!-- Load extension modules -->
27
+ <script src="../shared/config.js"></script>
28
+ <script src="../shared/bayan-renderer.js"></script>
29
+ <script src="../shared/bayan-ui.js"></script>
30
+ <script src="../shared/bayan-patches.js"></script>
31
+ <script src="../shared/bayan-api.js"></script>
32
+
33
+ <script>
34
+ const outputEl = document.getElementById('output');
35
+ const logEl = document.getElementById('log');
36
+ let passed = 0;
37
+ let failed = 0;
38
+ let currentGroup = null;
39
+
40
+ function log(msg) {
41
+ logEl.textContent += msg + '\n';
42
+ console.log(msg);
43
+ }
44
+
45
+ function group(name) {
46
+ currentGroup = document.createElement('div');
47
+ currentGroup.className = 'test-group';
48
+ currentGroup.innerHTML = `<h2 style="margin:0 0 8px;font-size:14px;color:#818cf8">${name}</h2>`;
49
+ outputEl.appendChild(currentGroup);
50
+ }
51
+
52
+ function assert(condition, message) {
53
+ const el = document.createElement('div');
54
+ el.className = 'result-line';
55
+ if (condition) {
56
+ passed++;
57
+ el.innerHTML = `<span class="pass">✅</span> ${message}`;
58
+ } else {
59
+ failed++;
60
+ el.innerHTML = `<span class="fail">❌</span> ${message}`;
61
+ }
62
+ currentGroup.appendChild(el);
63
+ }
64
+
65
+ function assertEqual(actual, expected, message) {
66
+ if (actual === expected) {
67
+ assert(true, message);
68
+ } else {
69
+ assert(false, message);
70
+ const detail = document.createElement('div');
71
+ detail.className = 'detail';
72
+ detail.textContent = `Expected: "${expected}" | Actual: "${actual}"`;
73
+ currentGroup.appendChild(detail);
74
+ }
75
+ }
76
+
77
+ function showArabic(text) {
78
+ const el = document.createElement('div');
79
+ el.className = 'arabic-text';
80
+ el.textContent = text;
81
+ currentGroup.appendChild(el);
82
+ }
83
+
84
+ // ══════════════════════════════════════════════
85
+ // MAIN TEST RUNNER
86
+ // ══════════════════════════════════════════════
87
+ (async function runTests() {
88
+
89
+ // ═══════════════════════════════════════════
90
+ // TEST CASE 1: Basic Correction Flow
91
+ // ═══════════════════════════════════════════
92
+ group('TEST CASE 1: API Verification');
93
+ const INPUT_1 = 'انا ذهبت الي المدرسه اليوم';
94
+ log(`[TC1] Input: "${INPUT_1}"`);
95
+
96
+ let data1;
97
+ try {
98
+ data1 = await bayanAnalyze(INPUT_1);
99
+ log(`[TC1] API response received: status=${data1.status}, suggestions=${data1.suggestions?.length}`);
100
+ assert(true, 'API call succeeded (no network error)');
101
+ } catch (err) {
102
+ assert(false, `API call failed: ${err.message}`);
103
+ log(`[TC1] ERROR: ${err.message}`);
104
+ return;
105
+ }
106
+
107
+ // Schema validation
108
+ assertEqual(data1.status, 'success', 'Response status = "success"');
109
+ assert(typeof data1.original === 'string', 'Response has "original" (string)');
110
+ assert(typeof data1.corrected === 'string', 'Response has "corrected" (string)');
111
+ assert(Array.isArray(data1.suggestions), 'Response has "suggestions" (array)');
112
+ assert(typeof data1.timing_ms === 'object', 'Response has "timing_ms" (object)');
113
+ assert(data1.suggestions.length > 0, `Suggestions returned: ${data1.suggestions.length}`);
114
+
115
+ // Verify suggestion schema
116
+ if (data1.suggestions.length > 0) {
117
+ const s = data1.suggestions[0];
118
+ assert(typeof s.id === 'string', 'Suggestion has "id"');
119
+ assert(typeof s.start === 'number', 'Suggestion has "start"');
120
+ assert(typeof s.end === 'number', 'Suggestion has "end"');
121
+ assert(typeof s.original === 'string', 'Suggestion has "original"');
122
+ assert(typeof s.correction === 'string', 'Suggestion has "correction"');
123
+ assert(typeof s.type === 'string', 'Suggestion has "type"');
124
+ }
125
+
126
+ // Verify offsets match original text
127
+ group('TEST CASE 1: Offset Correctness');
128
+ const sorted1 = sortSuggestions(data1.suggestions);
129
+ sorted1.forEach((s, i) => {
130
+ const extracted = data1.original.slice(s.start, s.end);
131
+ assertEqual(extracted, s.original, `Suggestion ${i} offset [${s.start}:${s.end}] extracts "${s.original}"`);
132
+ log(`[TC1] Suggestion ${i}: [${s.type}] "${s.original}" → "${s.correction}" offset=[${s.start}:${s.end}]`);
133
+ });
134
+
135
+ // Apply All test
136
+ group('TEST CASE 1: Apply All');
137
+ const correctedText1 = applyAllPatches(data1.original, data1.suggestions);
138
+ showArabic(correctedText1);
139
+ assertEqual(correctedText1, data1.corrected, 'applyAllPatches() === API corrected text');
140
+ log(`[TC1] applyAllPatches result: "${correctedText1}"`);
141
+ log(`[TC1] API corrected field: "${data1.corrected}"`);
142
+
143
+ // Highlighted text test
144
+ group('TEST CASE 1: Rendering');
145
+ const html1 = renderHighlightedText(data1.original, sorted1);
146
+ assert(html1.length > 0, 'renderHighlightedText produces non-empty HTML');
147
+ assert(html1.includes('bayan-spelling-error') || html1.includes('bayan-punctuation-suggestion'), 'HTML contains error highlight classes');
148
+ log(`[TC1] Highlighted HTML length: ${html1.length} chars`);
149
+
150
+ // Score test
151
+ group('TEST CASE 1: Score Calculation');
152
+ const counts1 = countByType(data1.suggestions);
153
+ const score1 = calculateWritingScore(counts1.spelling, counts1.grammar, counts1.punctuation);
154
+ assert(score1 >= 0 && score1 <= 100, `Score in valid range: ${score1}`);
155
+ log(`[TC1] Counts: spelling=${counts1.spelling}, grammar=${counts1.grammar}, punctuation=${counts1.punctuation}`);
156
+ log(`[TC1] Score: ${score1}/100`);
157
+
158
+ // ═══════════════════════════════════════════
159
+ // TEST CASE 2: Sequential Apply
160
+ // ═══════════════════════════════════════════
161
+ group('TEST CASE 2: API Call');
162
+ const INPUT_2 = 'انا ذاهب الي البت';
163
+ log(`\n[TC2] Input: "${INPUT_2}"`);
164
+
165
+ let data2;
166
+ try {
167
+ data2 = await bayanAnalyze(INPUT_2);
168
+ log(`[TC2] API response: status=${data2.status}, suggestions=${data2.suggestions?.length}`);
169
+ assert(true, 'API call succeeded');
170
+ assertEqual(data2.status, 'success', 'Status = success');
171
+ } catch (err) {
172
+ assert(false, `API call failed: ${err.message}`);
173
+ return;
174
+ }
175
+
176
+ // Show what the API actually detected
177
+ group('TEST CASE 2: API-Detected Corrections');
178
+ data2.suggestions.forEach((s, i) => {
179
+ assert(true, `[${s.type}] "${s.original}" → "${s.correction}" (${s.start}:${s.end})`);
180
+ });
181
+ showArabic(`API corrected: ${data2.corrected}`);
182
+
183
+ // Sequential apply using ACTUAL API response
184
+ group('TEST CASE 2: Sequential Apply (Actual API suggestions)');
185
+ let text2 = data2.original;
186
+ let sugs2 = sortSuggestions([...data2.suggestions]);
187
+ log(`[TC2] Starting sequential apply with ${sugs2.length} suggestions`);
188
+ log(`[TC2] Initial text: "${text2}"`);
189
+
190
+ const applyOrder = [...sugs2]; // Apply in sorted order
191
+ for (let i = 0; i < applyOrder.length; i++) {
192
+ const sug = sugs2.find(s => s.id === applyOrder[i].id);
193
+ if (!sug) {
194
+ log(`[TC2] Suggestion ${i} already removed, skipping`);
195
+ continue;
196
+ }
197
+
198
+ // Verify offset before apply
199
+ const beforeExtract = text2.slice(sug.start, sug.end);
200
+ assertEqual(beforeExtract, sug.original, `Step ${i+1}: Offset [${sug.start}:${sug.end}] extracts "${sug.original}" BEFORE apply`);
201
+
202
+ // Apply and rebase
203
+ const result = applyAndRebase(text2, sug, sug.correction, sugs2);
204
+ text2 = result.text;
205
+ sugs2 = result.suggestions;
206
+
207
+ log(`[TC2] Step ${i+1}: Applied "${sug.original}" → "${sug.correction}"`);
208
+ log(`[TC2] Step ${i+1}: Text now: "${text2}"`);
209
+ log(`[TC2] Step ${i+1}: Remaining suggestions: ${sugs2.length}`);
210
+ showArabic(`Step ${i+1}: ${text2}`);
211
+
212
+ // Verify ALL remaining suggestion offsets still extract correct text
213
+ sugs2.forEach((rs, j) => {
214
+ const extracted = text2.slice(rs.start, rs.end);
215
+ assertEqual(extracted, rs.original, `Step ${i+1}: Remaining[${j}] offset [${rs.start}:${rs.end}] extracts "${rs.original}"`);
216
+ });
217
+ }
218
+
219
+ assert(sugs2.length === 0, 'All suggestions applied (0 remaining)');
220
+ assertEqual(text2, data2.corrected, 'Sequential result === API corrected text');
221
+
222
+ // ═══════════════════════════════════════════
223
+ // TEST CASE 2b: Synthetic test with user's exact scenario
224
+ // ═══════════════════════════════════════════
225
+ group('TEST CASE 2b: Synthetic Sequential (User Scenario)');
226
+ // Simulate the exact scenario the user described,
227
+ // using manually-constructed suggestions
228
+ const SYNTH_TEXT = 'انا ذاهب الى البت';
229
+ const synthIdx_ana = SYNTH_TEXT.indexOf('انا');
230
+ const synthIdx_ala = SYNTH_TEXT.indexOf('الى');
231
+ const synthIdx_bet = SYNTH_TEXT.indexOf('البت');
232
+
233
+ log(`[TC2b] Synthetic text: "${SYNTH_TEXT}" (length: ${SYNTH_TEXT.length})`);
234
+ log(`[TC2b] indexOf('انا')=${synthIdx_ana}, indexOf('الى')=${synthIdx_ala}, indexOf('البت')=${synthIdx_bet}`);
235
+
236
+ const synthSugs = [
237
+ { id: 's1', start: synthIdx_ala, end: synthIdx_ala + 3, original: 'الى', correction: 'إلى', type: 'grammar' },
238
+ { id: 's2', start: synthIdx_bet, end: synthIdx_bet + 4, original: 'البت', correction: 'البيت', type: 'spelling' },
239
+ { id: 's3', start: synthIdx_ana, end: synthIdx_ana + 3, original: 'انا', correction: 'أنا', type: 'spelling' },
240
+ ];
241
+
242
+ // Verify offsets
243
+ synthSugs.forEach(s => {
244
+ assertEqual(SYNTH_TEXT.slice(s.start, s.end), s.original, `Offset check: [${s.start}:${s.end}] = "${s.original}"`);
245
+ });
246
+
247
+ // Step 1: Apply "الى" → "إلى"
248
+ let sText = SYNTH_TEXT;
249
+ let sSugs = [...synthSugs];
250
+ let r1 = applyAndRebase(sText, sSugs[0], 'إلى', sSugs);
251
+ sText = r1.text;
252
+ sSugs = r1.suggestions;
253
+ showArabic(`Step 1 (الى→إلى): ${sText}`);
254
+ assertEqual(sText, 'انا ذاهب إلى البت', 'Step 1 result correct');
255
+ assert(sSugs.length === 2, 'Step 1: 2 remaining');
256
+ // Verify remaining offsets
257
+ sSugs.forEach(s => {
258
+ assertEqual(sText.slice(s.start, s.end), s.original, `Step 1: Remaining "${s.original}" offset valid`);
259
+ });
260
+
261
+ // Step 2: Apply "البت" → "البيت" (should be at shifted offset)
262
+ const sugBet = sSugs.find(s => s.original === 'البت');
263
+ assert(!!sugBet, 'Found "البت" suggestion in remaining');
264
+ let r2 = applyAndRebase(sText, sugBet, 'البيت', sSugs);
265
+ sText = r2.text;
266
+ sSugs = r2.suggestions;
267
+ showArabic(`Step 2 (البت→البيت): ${sText}`);
268
+ assertEqual(sText, 'انا ذاهب إلى البيت', 'Step 2 result correct');
269
+ assert(sSugs.length === 1, 'Step 2: 1 remaining');
270
+ sSugs.forEach(s => {
271
+ assertEqual(sText.slice(s.start, s.end), s.original, `Step 2: Remaining "${s.original}" offset valid`);
272
+ });
273
+
274
+ // Step 3: Apply "انا" → "أنا"
275
+ const sugAna = sSugs.find(s => s.original === 'انا');
276
+ assert(!!sugAna, 'Found "انا" suggestion in remaining');
277
+ let r3 = applyAndRebase(sText, sugAna, 'أنا', sSugs);
278
+ sText = r3.text;
279
+ sSugs = r3.suggestions;
280
+ showArabic(`Step 3 (انا→أنا): ${sText}`);
281
+
282
+ // FINAL VERIFICATION
283
+ assertEqual(sText, 'أنا ذاهب إلى البيت', '🏁 FINAL RESULT matches expected');
284
+ assert(sSugs.length === 0, 'All suggestions consumed');
285
+
286
+ // Verify sequential === applyAllPatches
287
+ const bulkResult = applyAllPatches(SYNTH_TEXT, synthSugs);
288
+ assertEqual(sText, bulkResult, 'Sequential === applyAllPatches');
289
+
290
+ // ═══════════════════════════════════════════
291
+ // SUMMARY
292
+ // ═══════════════════════════════════════════
293
+ const summary = document.createElement('div');
294
+ summary.className = 'summary';
295
+ summary.style.color = failed > 0 ? '#ef4444' : '#22c55e';
296
+ summary.textContent = `Results: ${passed} passed, ${failed} failed`;
297
+ outputEl.appendChild(summary);
298
+
299
+ const verdict = document.createElement('div');
300
+ verdict.className = 'summary';
301
+ verdict.style.marginTop = '12px';
302
+ if (failed === 0) {
303
+ verdict.style.color = '#22c55e';
304
+ verdict.style.borderColor = '#22c55e';
305
+ verdict.textContent = 'HIGH-1 = COMPLETE (REAL VERIFIED) ✅';
306
+ } else {
307
+ verdict.style.color = '#ef4444';
308
+ verdict.style.borderColor = '#ef4444';
309
+ verdict.textContent = `HIGH-1 = INCOMPLETE (${failed} failures)`;
310
+ }
311
+ outputEl.appendChild(verdict);
312
+
313
+ log(`\n${'═'.repeat(50)}`);
314
+ log(`Results: ${passed} passed, ${failed} failed`);
315
+ log(`${'═'.repeat(50)}`);
316
+ })();
317
+ </script>
318
+ </body>
319
+ </html>
extension/tests/test_inline.html ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Bayan Inline Engine — Test</title>
6
+ <style>
7
+ body { font-family: 'Segoe UI', Tahoma, sans-serif; background: #1a1a2e; color: #f0f0f5; padding: 40px; }
8
+ h1 { color: #818cf8; margin-bottom: 20px; }
9
+ textarea { width: 100%; height: 150px; font-size: 16px; padding: 12px; border-radius: 8px;
10
+ border: 1px solid #3a3a4d; background: #22222e; color: #f0f0f5; direction: rtl; }
11
+ .field-group { margin-bottom: 30px; }
12
+ label { display: block; margin-bottom: 8px; color: #9898ad; }
13
+ [contenteditable] { width: 100%; min-height: 100px; font-size: 16px; padding: 12px; border-radius: 8px;
14
+ border: 1px solid #3a3a4d; background: #22222e; color: #f0f0f5; direction: rtl; outline: none; }
15
+ [contenteditable]:focus { border-color: #6366f1; }
16
+ .instructions { background: #22222e; padding: 16px; border-radius: 8px; margin-bottom: 30px; font-size: 13px; color: #9898ad; }
17
+ .instructions code { background: #2a2a38; padding: 2px 6px; border-radius: 4px; color: #818cf8; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <h1>🧪 اختبار محرك بيان المضمّن (Phase 6)</h1>
22
+
23
+ <div class="instructions">
24
+ <p><strong>كيفية الاختبار:</strong></p>
25
+ <ol>
26
+ <li>اكتب أو الصق نصاً عربياً يحتوي على أخطاء في أي حقل أدناه</li>
27
+ <li>انتظر ثانية واحدة — ستظهر أيقونة بيان الصغيرة</li>
28
+ <li>ستظهر الأخطاء بخطوط حمراء/برتقالية/زرقاء</li>
29
+ <li>انقر على كلمة مُعلّمة لرؤية الاقتراح</li>
30
+ </ol>
31
+ <p>نص اختباري: <code>انا ذهبت الي المدرسه اليوم وكان الجو جميل جدا</code></p>
32
+ </div>
33
+
34
+ <div class="field-group">
35
+ <label>📝 حقل نصي (textarea)</label>
36
+ <textarea id="test-textarea" placeholder="اكتب نصاً عربياً هنا..."></textarea>
37
+ </div>
38
+
39
+ <div class="field-group">
40
+ <label>✏️ حقل قابل للتحرير (contenteditable)</label>
41
+ <div contenteditable="true" id="test-contenteditable"></div>
42
+ </div>
43
+
44
+ <div class="field-group">
45
+ <label>🔤 حقل إدخال (input)</label>
46
+ <input type="text" id="test-input" style="width:100%;font-size:16px;padding:10px;border-radius:8px;border:1px solid #3a3a4d;background:#22222e;color:#f0f0f5;direction:rtl;" placeholder="اكتب هنا...">
47
+ </div>
48
+
49
+ <div id="console-log" style="margin-top: 30px; padding: 12px; background: #0f0f14; border-radius: 8px; font-family: monospace; font-size: 11px; color: #6b6b80; max-height: 200px; overflow-y: auto;">
50
+ <div>Bayan Inline Test Page Ready</div>
51
+ </div>
52
+
53
+ <script>
54
+ // Capture console.log/warn to show on page
55
+ const logDiv = document.getElementById('console-log');
56
+ const origLog = console.log;
57
+ const origWarn = console.warn;
58
+ console.log = (...args) => {
59
+ origLog(...args);
60
+ if (args[0]?.includes?.('[Bayan')) {
61
+ const el = document.createElement('div');
62
+ el.textContent = args.join(' ');
63
+ el.style.color = '#22c55e';
64
+ logDiv.appendChild(el);
65
+ logDiv.scrollTop = logDiv.scrollHeight;
66
+ }
67
+ };
68
+ console.warn = (...args) => {
69
+ origWarn(...args);
70
+ const el = document.createElement('div');
71
+ el.textContent = args.join(' ');
72
+ el.style.color = '#f59e0b';
73
+ logDiv.appendChild(el);
74
+ logDiv.scrollTop = logDiv.scrollHeight;
75
+ };
76
+ </script>
77
+ </body>
78
+ </html>
extension/tests/test_patches.html ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Bayan Patch Engine — Tests</title>
6
+ <style>
7
+ body { font-family: 'Segoe UI', Tahoma, sans-serif; background: #0f0f14; color: #f0f0f5; padding: 24px; }
8
+ h1 { color: #818cf8; font-size: 20px; margin-bottom: 20px; }
9
+ .test-group { margin-bottom: 16px; padding: 12px; background: #1a1a24; border-radius: 8px; border: 1px solid #2d2d3d; }
10
+ .test-group h2 { font-size: 14px; color: #9898ad; margin-bottom: 8px; }
11
+ .pass { color: #22c55e; }
12
+ .fail { color: #ef4444; font-weight: bold; }
13
+ .result-line { font-size: 13px; margin: 4px 0; font-family: monospace; }
14
+ .detail { color: #6b6b80; font-size: 12px; margin-left: 24px; }
15
+ .summary { margin-top: 24px; padding: 16px; background: #1a1a24; border-radius: 8px; border: 2px solid #818cf8; font-size: 16px; font-weight: bold; }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <h1>🧪 Bayan Patch Engine — Test Suite</h1>
20
+ <div id="output"></div>
21
+
22
+ <!-- Load modules in dependency order -->
23
+ <script src="../shared/config.js"></script>
24
+ <script src="../shared/bayan-renderer.js"></script>
25
+ <script src="../shared/bayan-ui.js"></script>
26
+ <script src="../shared/bayan-patches.js"></script>
27
+
28
+ <script>
29
+ const output = document.getElementById('output');
30
+ let passed = 0;
31
+ let failed = 0;
32
+ let currentGroup = null;
33
+
34
+ function group(name) {
35
+ currentGroup = document.createElement('div');
36
+ currentGroup.className = 'test-group';
37
+ currentGroup.innerHTML = `<h2>${name}</h2>`;
38
+ output.appendChild(currentGroup);
39
+ }
40
+
41
+ function assert(condition, message) {
42
+ const el = document.createElement('div');
43
+ el.className = 'result-line';
44
+ if (condition) {
45
+ passed++;
46
+ el.innerHTML = `<span class="pass">✅</span> ${message}`;
47
+ } else {
48
+ failed++;
49
+ el.innerHTML = `<span class="fail">❌</span> ${message}`;
50
+ }
51
+ currentGroup.appendChild(el);
52
+ }
53
+
54
+ function assertEqual(actual, expected, message) {
55
+ if (actual === expected) {
56
+ assert(true, message);
57
+ } else {
58
+ assert(false, message);
59
+ const detail = document.createElement('div');
60
+ detail.className = 'detail';
61
+ detail.textContent = `Expected: "${expected}" | Actual: "${actual}"`;
62
+ currentGroup.appendChild(detail);
63
+ }
64
+ }
65
+
66
+ // ══════════════════════════════════════════════════════
67
+ // Test data — offsets verified via JavaScript indexOf()
68
+ // ══════════════════════════════════════════════════════
69
+ //
70
+ // TEXT = 'أنا ذاهب الى البت والمدرسه' (length: 26)
71
+ //
72
+ // indexOf('الى') = 9 → slice(9, 12) = 'الى' (3 chars)
73
+ // indexOf('البت') = 13 → slice(13, 17) = 'البت' (4 chars)
74
+ // indexOf('المدرسه') = 19 → slice(19, 26) = 'المدرسه' (7 chars)
75
+ //
76
+
77
+ const TEXT = 'أنا ذاهب الى البت والمدرسه';
78
+
79
+ function makeSuggestions() {
80
+ return [
81
+ { id: 'a', start: 9, end: 12, original: 'الى', correction: 'إلى', type: 'grammar' },
82
+ { id: 'b', start: 13, end: 17, original: 'البت', correction: 'البيت', type: 'spelling' },
83
+ { id: 'c', start: 19, end: 26, original: 'المدرسه', correction: 'المدرسة', type: 'spelling' },
84
+ ];
85
+ }
86
+
87
+ // ── Prerequisite: verify test data offsets ──
88
+ group('PREREQ: Verify test data offsets');
89
+ assertEqual(TEXT.length, 26, 'Text length = 26');
90
+ assertEqual(TEXT.slice(9, 12), 'الى', 'slice(9,12) = "الى"');
91
+ assertEqual(TEXT.slice(13, 17), 'البت', 'slice(13,17) = "البت"');
92
+ assertEqual(TEXT.slice(19, 26), 'المدرسه', 'slice(19,26) = "المدرسه"');
93
+
94
+ // ══════════════════════════════════════════════════════
95
+ // TEST 1: Same-length replacement (delta = 0)
96
+ // "الى" (3) → "إلى" (3), delta = 0
97
+ // ══════════════════════════════════════════════════════
98
+ group('TEST 1: Same-length replacement (delta = 0)');
99
+ {
100
+ const suggestions = makeSuggestions();
101
+ const result = applyAndRebase(TEXT, suggestions[0], 'إلى', suggestions);
102
+
103
+ assertEqual(result.text, 'أنا ذاهب إلى البت والمدرسه', 'Text: "الى" → "إلى"');
104
+ assert(result.suggestions.length === 2, 'Removed applied (2 remaining)');
105
+ assertEqual(result.suggestions[0].start, 13, 'B.start unchanged (13)');
106
+ assertEqual(result.suggestions[0].end, 17, 'B.end unchanged (17)');
107
+ assertEqual(result.suggestions[1].start, 19, 'C.start unchanged (19)');
108
+ assertEqual(result.suggestions[1].end, 26, 'C.end unchanged (26)');
109
+ // Verify offsets extract correct text from modified string
110
+ assertEqual(result.text.slice(result.suggestions[0].start, result.suggestions[0].end), 'البت', 'Rebased B extracts "البت"');
111
+ assertEqual(result.text.slice(result.suggestions[1].start, result.suggestions[1].end), 'المدرسه', 'Rebased C extracts "المدرسه"');
112
+ }
113
+
114
+ // ══════════════════════════════════════════════════════
115
+ // TEST 2: Longer replacement (delta > 0)
116
+ // "البت" (4) → "البيت" (5), delta = +1
117
+ // ══════════════════════════════════════════════════════
118
+ group('TEST 2: Longer replacement (delta > 0)');
119
+ {
120
+ const suggestions = makeSuggestions();
121
+ const result = applyAndRebase(TEXT, suggestions[1], 'البيت', suggestions);
122
+
123
+ assertEqual(result.text, 'أنا ذاهب الى البيت والمدرسه', 'Text: "البت" → "البيت"');
124
+ assert(result.suggestions.length === 2, 'Removed applied (2 remaining)');
125
+
126
+ // A is BEFORE B — no shift
127
+ assertEqual(result.suggestions[0].start, 9, 'A.start unchanged (9)');
128
+ assertEqual(result.suggestions[0].end, 12, 'A.end unchanged (12)');
129
+
130
+ // C is AFTER B — shift by +1
131
+ assertEqual(result.suggestions[1].start, 20, 'C.start shifted 19→20 (+1)');
132
+ assertEqual(result.suggestions[1].end, 27, 'C.end shifted 26→27 (+1)');
133
+
134
+ // Verify rebased offsets
135
+ assertEqual(result.text.slice(result.suggestions[0].start, result.suggestions[0].end), 'الى', 'Rebased A extracts "الى"');
136
+ assertEqual(result.text.slice(result.suggestions[1].start, result.suggestions[1].end), 'المدرسه', 'Rebased C extracts "المدرسه"');
137
+ }
138
+
139
+ // ══════════════════════════════════════════════════════
140
+ // TEST 3: Shorter replacement (delta < 0)
141
+ // ══════════════════════════════════════════════════════
142
+ group('TEST 3: Shorter replacement (delta < 0)');
143
+ {
144
+ const text = 'هذا النصص خاطئ والكلمه';
145
+ // Verify offsets
146
+ const idx1 = text.indexOf('النصص');
147
+ const idx2 = text.indexOf('والكلمه');
148
+ const suggestions = [
149
+ { id: 'x', start: idx1, end: idx1 + 5, original: 'النصص', correction: 'النص', type: 'spelling' },
150
+ { id: 'y', start: idx2, end: idx2 + 7, original: 'والكلمه', correction: 'والكلمة', type: 'spelling' },
151
+ ];
152
+
153
+ // "النصص" (5) → "النص" (4), delta = -1
154
+ const result = applyAndRebase(text, suggestions[0], 'النص', suggestions);
155
+
156
+ assertEqual(result.text, 'هذا النص خاطئ والكلمه', 'Text: "النصص" → "النص"');
157
+ assert(result.suggestions.length === 1, '1 remaining');
158
+ assertEqual(result.suggestions[0].start, idx2 - 1, 'Y.start shifted by -1');
159
+ assertEqual(result.suggestions[0].end, idx2 + 7 - 1, 'Y.end shifted by -1');
160
+ assertEqual(
161
+ result.text.slice(result.suggestions[0].start, result.suggestions[0].end),
162
+ 'والكلمه', 'Rebased Y extracts "والكلمه"'
163
+ );
164
+ }
165
+
166
+ // ══════════════════════════════════════════════════════
167
+ // TEST 4: Multiple sequential applies
168
+ // ══════════════════════════════════════════════════════
169
+ group('TEST 4: Multiple sequential applies');
170
+ {
171
+ let text = TEXT;
172
+ let suggestions = makeSuggestions();
173
+
174
+ // Apply A first (same-length, delta=0)
175
+ let r1 = applyAndRebase(text, suggestions[0], 'إلى', suggestions);
176
+ text = r1.text;
177
+ suggestions = r1.suggestions;
178
+ assertEqual(text, 'أنا ذاهب إلى البت والمدرسه', 'After apply A');
179
+ assert(suggestions.length === 2, '2 remaining after A');
180
+
181
+ // Apply B (longer, delta=+1)
182
+ let r2 = applyAndRebase(text, suggestions[0], 'البيت', suggestions);
183
+ text = r2.text;
184
+ suggestions = r2.suggestions;
185
+ assertEqual(text, 'أنا ذاهب إلى البيت والمدرسه', 'After apply B');
186
+ assert(suggestions.length === 1, '1 remaining after B');
187
+
188
+ // Apply C (same-length, delta=0)
189
+ let r3 = applyAndRebase(text, suggestions[0], 'المدرسة', suggestions);
190
+ text = r3.text;
191
+ suggestions = r3.suggestions;
192
+ assertEqual(text, 'أنا ذاهب إلى البيت والمدرسة', 'After apply C — fully corrected');
193
+ assert(suggestions.length === 0, '0 remaining — all applied');
194
+
195
+ // Verify equals applyAllPatches
196
+ const expectedFull = applyAllPatches(TEXT, makeSuggestions());
197
+ assertEqual(text, expectedFull, 'Sequential === applyAllPatches');
198
+ }
199
+
200
+ // ══════════════════════════════════════════════════════
201
+ // TEST 5: Apply last suggestion (no rebase needed)
202
+ // ══════════════════════════════════════════════════════
203
+ group('TEST 5: Apply last suggestion');
204
+ {
205
+ const suggestions = makeSuggestions();
206
+ const result = applyAndRebase(TEXT, suggestions[2], 'المدرسة', suggestions);
207
+
208
+ assertEqual(result.text, 'أنا ذاهب الى البت والمدرسة', 'Last suggestion applied');
209
+ assert(result.suggestions.length === 2, '2 remaining');
210
+ assertEqual(result.suggestions[0].start, 9, 'A unchanged');
211
+ assertEqual(result.suggestions[1].start, 13, 'B unchanged');
212
+ // Verify offsets still work
213
+ assertEqual(result.text.slice(result.suggestions[0].start, result.suggestions[0].end), 'الى', 'A extracts correctly');
214
+ assertEqual(result.text.slice(result.suggestions[1].start, result.suggestions[1].end), 'البت', 'B extracts correctly');
215
+ }
216
+
217
+ // ══════════════════════════════════════════════════════
218
+ // TEST 6: Dismiss (removeSuggestion — no rebase)
219
+ // ══════════════════════════════════════════════════════
220
+ group('TEST 6: Dismiss (removeSuggestion)');
221
+ {
222
+ const suggestions = makeSuggestions();
223
+ const remaining = removeSuggestion(suggestions, 'b');
224
+ assert(remaining.length === 2, '2 remaining after dismiss');
225
+ assert(remaining.every(s => s.id !== 'b'), 'Dismissed suggestion removed');
226
+ assertEqual(remaining[0].start, 9, 'A offset unchanged');
227
+ assertEqual(remaining[1].start, 19, 'C offset unchanged');
228
+ }
229
+
230
+ // ══════════════════════════════════════════════════════
231
+ // TEST 7: applyAllPatches (reverse-order)
232
+ // ══════════════════════════════════════════════════════
233
+ group('TEST 7: applyAllPatches');
234
+ {
235
+ const result = applyAllPatches(TEXT, makeSuggestions());
236
+ assertEqual(result, 'أنا ذاهب إلى البيت والمدرسة', 'All patches applied correctly');
237
+ }
238
+
239
+ // ══════════════════════════════════════════════════════
240
+ // TEST 8: countByType
241
+ // ══════════════════════════════════════════════════════
242
+ group('TEST 8: countByType');
243
+ {
244
+ const counts = countByType(makeSuggestions());
245
+ assertEqual(counts.spelling, 2, '2 spelling');
246
+ assertEqual(counts.grammar, 1, '1 grammar');
247
+ assertEqual(counts.punctuation, 0, '0 punctuation');
248
+ }
249
+
250
+ // ══════════════════════════════════════════════════════
251
+ // TEST 9: Apply first suggestion at start=0
252
+ // ══════════════════════════════════════════════════════
253
+ group('TEST 9: Apply first suggestion (start=0)');
254
+ {
255
+ const text = 'الخطاء في البداية';
256
+ const i1 = text.indexOf('الخطاء');
257
+ const suggestions = [
258
+ { id: 'f', start: i1, end: i1 + 6, original: 'الخطاء', correction: 'الخطأ', type: 'spelling' },
259
+ { id: 'g', start: 10, end: 17, original: 'البداية', correction: 'البدايه', type: 'grammar' },
260
+ ];
261
+
262
+ // "الخطاء" (6) → "الخطأ" (5), delta = -1
263
+ const result = applyAndRebase(text, suggestions[0], 'الخطأ', suggestions);
264
+ assertEqual(result.text.slice(0, 5), 'الخطأ', 'First word replaced');
265
+ assertEqual(result.suggestions[0].start, 9, 'G.start shifted 10→9 (-1)');
266
+ assertEqual(result.suggestions[0].end, 16, 'G.end shifted 17→16 (-1)');
267
+ }
268
+
269
+ // ══════════════════════════════════════════════════════
270
+ // Summary
271
+ // ═══════════════════════���══════════════════════════════
272
+ const summary = document.createElement('div');
273
+ summary.className = 'summary';
274
+ summary.style.color = failed > 0 ? '#ef4444' : '#22c55e';
275
+ summary.textContent = `Results: ${passed} passed, ${failed} failed`;
276
+ output.appendChild(summary);
277
+
278
+ // Also log to console for easy capture
279
+ console.log(`[TEST RESULTS] ${passed} passed, ${failed} failed`);
280
+ </script>
281
+ </body>
282
+ </html>
extension/tests/test_patches.js ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bayan Chrome Extension — Patch Engine Tests
3
+ *
4
+ * Tests for HIGH-1 fix: applyAndRebase()
5
+ * Run with: node extension/tests/test_patches.js
6
+ *
7
+ * Test scenarios:
8
+ * 1. Shorter replacement (delta < 0)
9
+ * 2. Longer replacement (delta > 0)
10
+ * 3. Same-length replacement (delta = 0)
11
+ * 4. Multiple sequential applies
12
+ * 5. Apply first suggestion (edge case)
13
+ * 6. Apply last suggestion (edge case)
14
+ * 7. Dismiss (no rebase needed)
15
+ * 8. Apply all patches
16
+ */
17
+
18
+ // ── Load modules (Node.js — no DOM required) ──
19
+ // These are global-scope scripts, so we eval them
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ function loadModule(filename) {
24
+ const code = fs.readFileSync(path.join(__dirname, '..', 'shared', filename), 'utf-8');
25
+ eval(code);
26
+ }
27
+
28
+ // Load in dependency order
29
+ loadModule('config.js');
30
+ loadModule('bayan-renderer.js');
31
+ loadModule('bayan-ui.js');
32
+ loadModule('bayan-patches.js');
33
+
34
+ // ── Test utilities ──
35
+ let passed = 0;
36
+ let failed = 0;
37
+
38
+ function assert(condition, message) {
39
+ if (condition) {
40
+ passed++;
41
+ console.log(` ✅ ${message}`);
42
+ } else {
43
+ failed++;
44
+ console.error(` ❌ ${message}`);
45
+ }
46
+ }
47
+
48
+ function assertEqual(actual, expected, message) {
49
+ if (actual === expected) {
50
+ passed++;
51
+ console.log(` ✅ ${message}`);
52
+ } else {
53
+ failed++;
54
+ console.error(` ❌ ${message}`);
55
+ console.error(` Expected: "${expected}"`);
56
+ console.error(` Actual: "${actual}"`);
57
+ }
58
+ }
59
+
60
+ // ══════════════════════════════════════════════════════════
61
+ // Test data: Arabic text with 3 suggestions
62
+ // ══════════════════════════════════════════════════════════
63
+ //
64
+ // Text: "أنا ذاهب الى البت والمدرسه"
65
+ // 0123456789012345678901234567
66
+ //
67
+ // Suggestion A: "الى" → "إلى" (start:10, end:13) — same length (3→3, delta=0)
68
+ // Suggestion B: "البت" → "البيت" (start:14, end:17) — longer (+1, delta=+1)
69
+ // Suggestion C: "المدرسه" → "المدرسة" (start:19, end:26) — same length (7→7, delta=0)
70
+ //
71
+
72
+ const TEXT = 'أنا ذاهب الى البت والمدرسه';
73
+
74
+ function makeSuggestions() {
75
+ return [
76
+ { id: 'a', start: 10, end: 13, original: 'الى', correction: 'إلى', type: 'grammar' },
77
+ { id: 'b', start: 14, end: 17, original: 'البت', correction: 'البيت', type: 'spelling' },
78
+ { id: 'c', start: 19, end: 26, original: 'المدرسه', correction: 'المدرسة', type: 'spelling' },
79
+ ];
80
+ }
81
+
82
+ // ══════════════════════════════════════════════════════════
83
+ // TEST 1: Same-length replacement (delta = 0)
84
+ // ══════════════════════════════════════════════════════════
85
+ console.log('\n── TEST 1: Same-length replacement (delta = 0) ──');
86
+ {
87
+ const suggestions = makeSuggestions();
88
+ const result = applyAndRebase(TEXT, suggestions[0], 'إلى', suggestions);
89
+
90
+ assertEqual(result.text, 'أنا ذاهب إلى البت والمدرسه', 'Text: "الى" → "إلى"');
91
+ assert(result.suggestions.length === 2, 'Removed applied suggestion (2 remaining)');
92
+ assertEqual(result.suggestions[0].start, 14, 'Suggestion B start unchanged (14)');
93
+ assertEqual(result.suggestions[0].end, 17, 'Suggestion B end unchanged (17)');
94
+ assertEqual(result.suggestions[1].start, 19, 'Suggestion C start unchanged (19)');
95
+ assertEqual(result.suggestions[1].end, 26, 'Suggestion C end unchanged (26)');
96
+ }
97
+
98
+ // ══════════════════════════════════════════════════════════
99
+ // TEST 2: Longer replacement (delta > 0)
100
+ // ══════════════════════════════════════════════════════════
101
+ console.log('\n── TEST 2: Longer replacement (delta > 0) ──');
102
+ {
103
+ const suggestions = makeSuggestions();
104
+ // Apply B: "البت" (3 chars) → "البيت" (5 chars), delta = +2
105
+ const result = applyAndRebase(TEXT, suggestions[1], 'البيت', suggestions);
106
+
107
+ assertEqual(result.text, 'أنا ذاهب الى البيت والمدرسه', 'Text: "البت" → "البيت"');
108
+ assert(result.suggestions.length === 2, 'Removed applied suggestion (2 remaining)');
109
+
110
+ // A is BEFORE B — should NOT shift
111
+ assertEqual(result.suggestions[0].start, 10, 'Suggestion A start unchanged (10)');
112
+ assertEqual(result.suggestions[0].end, 13, 'Suggestion A end unchanged (13)');
113
+
114
+ // C is AFTER B — should shift by +2
115
+ assertEqual(result.suggestions[1].start, 21, 'Suggestion C start shifted 19→21 (+2)');
116
+ assertEqual(result.suggestions[1].end, 28, 'Suggestion C end shifted 26→28 (+2)');
117
+
118
+ // Verify the shifted offsets are correct
119
+ assertEqual(result.text.substring(result.suggestions[1].start, result.suggestions[1].end),
120
+ 'المدرسه', 'Rebased offset C extracts correct text');
121
+ }
122
+
123
+ // ══════════════════════════════════════════════════════════
124
+ // TEST 3: Shorter replacement (delta < 0)
125
+ // ══════════════════════════════════════════════════════════
126
+ console.log('\n── TEST 3: Shorter replacement (delta < 0) ──');
127
+ {
128
+ // Custom text for this test
129
+ const text = 'هذا النصص خاطئ والكلمه خطأ';
130
+ // "النصص" (5) → "النص" (4), delta = -1
131
+ const suggestions = [
132
+ { id: 'x', start: 4, end: 9, original: 'النصص', correction: 'النص', type: 'spelling' },
133
+ { id: 'y', start: 17, end: 23, original: 'والكلمه', correction: 'والكلمة', type: 'spelling' },
134
+ ];
135
+
136
+ const result = applyAndRebase(text, suggestions[0], 'النص', suggestions);
137
+
138
+ assertEqual(result.text, 'هذا النص خاطئ والكلمه خطأ', 'Text: "النصص" → "النص"');
139
+ assert(result.suggestions.length === 1, '1 remaining');
140
+ assertEqual(result.suggestions[0].start, 16, 'Suggestion Y start shifted 17→16 (-1)');
141
+ assertEqual(result.suggestions[0].end, 22, 'Suggestion Y end shifted 23→22 (-1)');
142
+ assertEqual(result.text.substring(result.suggestions[0].start, result.suggestions[0].end),
143
+ 'والكلمه', 'Rebased offset Y extracts correct text');
144
+ }
145
+
146
+ // ══════════════════════════════════════════════════════════
147
+ // TEST 4: Multiple sequential applies
148
+ // ══════════════════════════════════════════════════════════
149
+ console.log('\n── TEST 4: Multiple sequential applies ──');
150
+ {
151
+ let text = TEXT;
152
+ let suggestions = makeSuggestions();
153
+
154
+ // Apply A first (same-length)
155
+ let r1 = applyAndRebase(text, suggestions[0], 'إلى', suggestions);
156
+ text = r1.text;
157
+ suggestions = r1.suggestions;
158
+ assertEqual(text, 'أنا ذاهب إلى البت والمدرسه', 'After apply A');
159
+ assert(suggestions.length === 2, '2 remaining after A');
160
+
161
+ // Apply B (longer, +2)
162
+ let r2 = applyAndRebase(text, suggestions[0], 'البيت', suggestions);
163
+ text = r2.text;
164
+ suggestions = r2.suggestions;
165
+ assertEqual(text, 'أنا ذاهب إلى البيت والمدرسه', 'After apply B');
166
+ assert(suggestions.length === 1, '1 remaining after B');
167
+
168
+ // Apply C (same-length) — offsets should be correctly rebased
169
+ let r3 = applyAndRebase(text, suggestions[0], 'المدرسة', suggestions);
170
+ text = r3.text;
171
+ suggestions = r3.suggestions;
172
+ assertEqual(text, 'أنا ذاهب إلى البيت والمدرسة', 'After apply C — fully corrected');
173
+ assert(suggestions.length === 0, '0 remaining — all applied');
174
+
175
+ // Verify final text matches applyAllPatches
176
+ const expectedFull = applyAllPatches(TEXT, makeSuggestions());
177
+ assertEqual(text, expectedFull, 'Sequential applies produce same result as applyAllPatches');
178
+ }
179
+
180
+ // ══════════════════════════════════════════════════════════
181
+ // TEST 5: Apply first suggestion (edge case)
182
+ // ══════════════════════════════════════════════════════════
183
+ console.log('\n── TEST 5: Apply first suggestion ──');
184
+ {
185
+ const text = 'الخطاء في البداية ثم نص صحيح';
186
+ const suggestions = [
187
+ { id: 'f', start: 0, end: 6, original: 'الخطاء', correction: 'الخطأ', type: 'spelling' },
188
+ { id: 'g', start: 20, end: 23, original: 'ثم ', correction: 'ثمّ ', type: 'grammar' },
189
+ ];
190
+
191
+ const result = applyAndRebase(text, suggestions[0], 'الخطأ', suggestions);
192
+ assertEqual(result.text.substring(0, 5), 'الخطأ', 'First word replaced correctly');
193
+ // delta = 5 - 6 = -1
194
+ assertEqual(result.suggestions[0].start, 19, 'Second suggestion shifted by -1');
195
+ assertEqual(result.suggestions[0].end, 22, 'Second suggestion end shifted by -1');
196
+ }
197
+
198
+ // ══════════════════════════════════════════════════════════
199
+ // TEST 6: Apply last suggestion (edge case)
200
+ // ══════════════════════════════════════════���═══════════════
201
+ console.log('\n── TEST 6: Apply last suggestion ──');
202
+ {
203
+ const suggestions = makeSuggestions();
204
+ // Apply C (last one) — no suggestions after it to rebase
205
+ const result = applyAndRebase(TEXT, suggestions[2], 'المدرسة', suggestions);
206
+
207
+ assertEqual(result.text, 'أنا ذاهب الى البت والمدرسة', 'Last suggestion applied');
208
+ assert(result.suggestions.length === 2, '2 remaining');
209
+ assertEqual(result.suggestions[0].start, 10, 'A unchanged (before C)');
210
+ assertEqual(result.suggestions[1].start, 14, 'B unchanged (before C)');
211
+ }
212
+
213
+ // ══════════════════════════════════════════════════════════
214
+ // TEST 7: Dismiss (keep original)
215
+ // ══════════════════════════════════════════════════════════
216
+ console.log('\n── TEST 7: Dismiss (removeSuggestion) ──');
217
+ {
218
+ const suggestions = makeSuggestions();
219
+ const remaining = removeSuggestion(suggestions, 'b');
220
+ assert(remaining.length === 2, '2 remaining after dismiss');
221
+ assert(remaining.every(s => s.id !== 'b'), 'Dismissed suggestion removed');
222
+ // Offsets of remaining should be unchanged (no rebase on dismiss)
223
+ assertEqual(remaining[0].start, 10, 'A unchanged');
224
+ assertEqual(remaining[1].start, 19, 'C unchanged');
225
+ }
226
+
227
+ // ══════════════════════════════════════════════════════════
228
+ // TEST 8: applyAllPatches
229
+ // ══════════════════════════════════════════════════════════
230
+ console.log('\n── TEST 8: applyAllPatches ──');
231
+ {
232
+ const result = applyAllPatches(TEXT, makeSuggestions());
233
+ assertEqual(result, 'أنا ذاهب إلى البيت والمدرسة', 'All patches applied correctly');
234
+ }
235
+
236
+ // ══════════════════════════════════════════════════════════
237
+ // TEST 9: countByType
238
+ // ══════════════════════════════════════════════════════════
239
+ console.log('\n── TEST 9: countByType ──');
240
+ {
241
+ const counts = countByType(makeSuggestions());
242
+ assertEqual(counts.spelling, 2, '2 spelling');
243
+ assertEqual(counts.grammar, 1, '1 grammar');
244
+ assertEqual(counts.punctuation, 0, '0 punctuation');
245
+ }
246
+
247
+ // ══════════════════════════════════════════════════════════
248
+ // Summary
249
+ // ══════════════════════════════════════════════════════════
250
+ console.log(`\n${'═'.repeat(50)}`);
251
+ console.log(`Results: ${passed} passed, ${failed} failed`);
252
+ console.log(`${'═'.repeat(50)}`);
253
+ process.exit(failed > 0 ? 1 : 0);
extension/tests/test_patches.ps1 ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bayan Patch Engine Tests — PowerShell Runner
2
+ # Evaluates the JavaScript test logic without Node.js
3
+
4
+ $passed = 0
5
+ $failed = 0
6
+
7
+ function Assert-Equal($actual, $expected, $message) {
8
+ if ($actual -eq $expected) {
9
+ $script:passed++
10
+ Write-Host " ✅ $message" -ForegroundColor Green
11
+ } else {
12
+ $script:failed++
13
+ Write-Host " ❌ $message" -ForegroundColor Red
14
+ Write-Host " Expected: `"$expected`"" -ForegroundColor DarkGray
15
+ Write-Host " Actual: `"$actual`"" -ForegroundColor DarkGray
16
+ }
17
+ }
18
+
19
+ function Assert-True($condition, $message) {
20
+ if ($condition) {
21
+ $script:passed++
22
+ Write-Host " ✅ $message" -ForegroundColor Green
23
+ } else {
24
+ $script:failed++
25
+ Write-Host " ❌ $message" -ForegroundColor Red
26
+ }
27
+ }
28
+
29
+ # ── Simulate applyAndRebase in PowerShell ──
30
+ function ApplyAndRebase($text, $applied, $replacement, $suggestions) {
31
+ # Apply patch
32
+ $newText = $text.Substring(0, $applied.start) + $replacement + $text.Substring($applied.end)
33
+
34
+ # Calculate delta
35
+ $originalSpanLength = $applied.end - $applied.start
36
+ $delta = $replacement.Length - $originalSpanLength
37
+
38
+ # Remove applied and rebase
39
+ $rebased = @()
40
+ foreach ($s in $suggestions) {
41
+ if ($s.id -eq $applied.id) { continue }
42
+ if ($s.start -ge $applied.end) {
43
+ $rebased += @{
44
+ id = $s.id; start = $s.start + $delta; end = $s.end + $delta
45
+ original = $s.original; correction = $s.correction; type = $s.type
46
+ }
47
+ } else {
48
+ $rebased += $s
49
+ }
50
+ }
51
+
52
+ return @{ text = $newText; suggestions = $rebased }
53
+ }
54
+
55
+ function ApplyAllPatches($text, $suggestions) {
56
+ $sorted = $suggestions | Sort-Object { $_.start } -Descending
57
+ $result = $text
58
+ foreach ($s in $sorted) {
59
+ $result = $result.Substring(0, $s.start) + $s.correction + $result.Substring($s.end)
60
+ }
61
+ return $result
62
+ }
63
+
64
+ # ── Test Data ──
65
+ $TEXT = 'أنا ذاهب الى البت والمدرسه'
66
+
67
+ function MakeSuggestions {
68
+ return @(
69
+ @{ id='a'; start=10; end=13; original='الى'; correction='إلى'; type='grammar' },
70
+ @{ id='b'; start=14; end=17; original='البت'; correction='البيت'; type='spelling' },
71
+ @{ id='c'; start=19; end=26; original='المدرسه'; correction='المدرسة'; type='spelling' }
72
+ )
73
+ }
74
+
75
+ # ══════════════════════════════════════════════════════════
76
+ # TEST 1: Same-length replacement (delta = 0)
77
+ # ══════════════════════════════════════════════════════════
78
+ Write-Host "`n── TEST 1: Same-length replacement (delta = 0) ──" -ForegroundColor Cyan
79
+ $suggestions = MakeSuggestions
80
+ $result = ApplyAndRebase $TEXT $suggestions[0] 'إلى' $suggestions
81
+ Assert-Equal $result.text 'أنا ذاهب إلى البت والمدرسه' 'Text: الى → إلى'
82
+ Assert-True ($result.suggestions.Count -eq 2) 'Removed applied suggestion (2 remaining)'
83
+ Assert-Equal $result.suggestions[0].start 14 'Suggestion B start unchanged (14)'
84
+ Assert-Equal $result.suggestions[0].end 17 'Suggestion B end unchanged (17)'
85
+ Assert-Equal $result.suggestions[1].start 19 'Suggestion C start unchanged (19)'
86
+ Assert-Equal $result.suggestions[1].end 26 'Suggestion C end unchanged (26)'
87
+
88
+ # ══════════════════════════════════════════════════════════
89
+ # TEST 2: Longer replacement (delta > 0)
90
+ # ══════════════════════════════════════════════════════════
91
+ Write-Host "`n── TEST 2: Longer replacement (delta > 0) ──" -ForegroundColor Cyan
92
+ $suggestions = MakeSuggestions
93
+ $result = ApplyAndRebase $TEXT $suggestions[1] 'البيت' $suggestions
94
+ Assert-Equal $result.text 'أنا ذاهب الى البيت والمدرسه' 'Text: البت → البيت'
95
+ Assert-True ($result.suggestions.Count -eq 2) 'Removed applied suggestion (2 remaining)'
96
+ Assert-Equal $result.suggestions[0].start 10 'Suggestion A start unchanged (10)'
97
+ Assert-Equal $result.suggestions[0].end 13 'Suggestion A end unchanged (13)'
98
+ Assert-Equal $result.suggestions[1].start 21 'Suggestion C start shifted 19→21 (+2)'
99
+ Assert-Equal $result.suggestions[1].end 28 'Suggestion C end shifted 26→28 (+2)'
100
+ $extracted = $result.text.Substring($result.suggestions[1].start, $result.suggestions[1].end - $result.suggestions[1].start)
101
+ Assert-Equal $extracted 'المدرسه' 'Rebased offset C extracts correct text'
102
+
103
+ # ══════════════════════════════════════════════════════════
104
+ # TEST 3: Shorter replacement (delta < 0)
105
+ # ══════════════════════════════════════════════════════════
106
+ Write-Host "`n── TEST 3: Shorter replacement (delta < 0) ──" -ForegroundColor Cyan
107
+ $text3 = 'هذا النصص خاطئ والكلمه خطأ'
108
+ $sugs3 = @(
109
+ @{ id='x'; start=4; end=9; original='النصص'; correction='النص'; type='spelling' },
110
+ @{ id='y'; start=17; end=23; original='والكلمه'; correction='والكلمة'; type='spelling' }
111
+ )
112
+ $result = ApplyAndRebase $text3 $sugs3[0] 'النص' $sugs3
113
+ Assert-Equal $result.text 'هذا النص خاطئ والكلمه خطأ' 'Text: النصص → النص'
114
+ Assert-True ($result.suggestions.Count -eq 1) '1 remaining'
115
+ Assert-Equal $result.suggestions[0].start 16 'Suggestion Y start shifted 17→16 (-1)'
116
+ Assert-Equal $result.suggestions[0].end 22 'Suggestion Y end shifted 23→22 (-1)'
117
+ $extracted = $result.text.Substring($result.suggestions[0].start, $result.suggestions[0].end - $result.suggestions[0].start)
118
+ Assert-Equal $extracted 'والكلمه' 'Rebased offset Y extracts correct text'
119
+
120
+ # ══════════════════════════════════════════════════════════
121
+ # TEST 4: Multiple sequential applies
122
+ # ══════════════════════════════════════════════════════════
123
+ Write-Host "`n── TEST 4: Multiple sequential applies ──" -ForegroundColor Cyan
124
+ $text4 = $TEXT
125
+ $sugs4 = MakeSuggestions
126
+ $r1 = ApplyAndRebase $text4 $sugs4[0] 'إلى' $sugs4
127
+ $text4 = $r1.text; $sugs4 = $r1.suggestions
128
+ Assert-Equal $text4 'أنا ذاهب إلى البت والمدرسه' 'After apply A'
129
+ Assert-True ($sugs4.Count -eq 2) '2 remaining after A'
130
+
131
+ $r2 = ApplyAndRebase $text4 $sugs4[0] 'البيت' $sugs4
132
+ $text4 = $r2.text; $sugs4 = $r2.suggestions
133
+ Assert-Equal $text4 'أنا ذاهب إلى البيت والمدرسه' 'After apply B'
134
+ Assert-True ($sugs4.Count -eq 1) '1 remaining after B'
135
+
136
+ $r3 = ApplyAndRebase $text4 $sugs4[0] 'المدرسة' $sugs4
137
+ $text4 = $r3.text; $sugs4 = $r3.suggestions
138
+ Assert-Equal $text4 'أنا ذاهب إلى البيت والمدرسة' 'After apply C — fully corrected'
139
+ Assert-True ($sugs4.Count -eq 0) '0 remaining — all applied'
140
+
141
+ $expectedFull = ApplyAllPatches $TEXT (MakeSuggestions)
142
+ Assert-Equal $text4 $expectedFull 'Sequential === applyAllPatches'
143
+
144
+ # ══════════════════════════════════════════════════════════
145
+ # TEST 5: Apply last suggestion
146
+ # ══════════════════════════════════════════════════════════
147
+ Write-Host "`n── TEST 5: Apply last suggestion ──" -ForegroundColor Cyan
148
+ $sugs5 = MakeSuggestions
149
+ $result = ApplyAndRebase $TEXT $sugs5[2] 'المدرسة' $sugs5
150
+ Assert-Equal $result.text 'أنا ذاهب الى البت والمدرسة' 'Last suggestion applied'
151
+ Assert-True ($result.suggestions.Count -eq 2) '2 remaining'
152
+ Assert-Equal $result.suggestions[0].start 10 'A unchanged (before C)'
153
+ Assert-Equal $result.suggestions[1].start 14 'B unchanged (before C)'
154
+
155
+ # ══════════════════════════════════════════════════════════
156
+ # TEST 6: applyAllPatches
157
+ # ══════════════════════════════════════════════════════════
158
+ Write-Host "`n── TEST 6: applyAllPatches ──" -ForegroundColor Cyan
159
+ $resultAll = ApplyAllPatches $TEXT (MakeSuggestions)
160
+ Assert-Equal $resultAll 'أنا ذاهب إلى البيت والمدرسة' 'All patches applied correctly'
161
+
162
+ # ══════════════════════════════════════════════════════════
163
+ # Summary
164
+ # ══════════════════════════════════════════════════════════
165
+ Write-Host "`n$('═' * 50)"
166
+ if ($failed -gt 0) {
167
+ Write-Host "Results: $passed passed, $failed FAILED" -ForegroundColor Red
168
+ exit 1
169
+ } else {
170
+ Write-Host "Results: $passed passed, $failed failed" -ForegroundColor Green
171
+ exit 0
172
+ }