rules-builder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1452 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * RulesBuilder (combined, single-file) — text-analytics rules builder.
5
+ *
6
+ * This file inlines SlateMatchEditor so it can be dropped into another project
7
+ * as ONE file. To split later: move the "Inlined Slate match editor" section
8
+ * (its imports + the declare-module block + the SlateMatchEditor component) back
9
+ * into its own file and re-export it.
10
+ *
11
+ * Deps: react, lucide-react, slate, slate-react, slate-history (+ slate-dom peer).
12
+ */
13
+
14
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
15
+
16
+ import { ArrowUpRight, Ban, BookText, Check, ChevronDown, ChevronRight, CircleCheck, Code2, Folder, Lightbulb, Minus, Pencil, Plus, Search, Tag, Trash2, TriangleAlert, X } from 'lucide-react';
17
+
18
+ import { BaseEditor, Descendant, Editor, Node as SlateNode, Path, Range, Text, Transforms, createEditor } from 'slate';
19
+ import { HistoryEditor, withHistory } from 'slate-history';
20
+ import { Editable, ReactEditor, Slate, withReact } from 'slate-react';
21
+
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+ // Inlined Slate match editor (was src/app/components/SlateMatchEditor.tsx)
24
+ // ═══════════════════════════════════════════════════════════════════════════
25
+
26
+ type CustomText = { text: string; op?: boolean; group?: boolean; phrase?: boolean; special?: boolean };
27
+ type DecRange = Range & { op?: boolean; group?: boolean; phrase?: boolean; special?: boolean };
28
+
29
+ declare module 'slate' {
30
+ interface CustomTypes {
31
+ Editor: BaseEditor & ReactEditor & HistoryEditor;
32
+ Element: { type: 'line'; children: CustomText[] };
33
+ Text: CustomText;
34
+ }
35
+ }
36
+
37
+ type Group = { name: string; terms: string[] };
38
+
39
+ // Imperative handle so the parent's operator pills / steppers can insert at the caret.
40
+ export type SlateMatchEditorHandle = { insert: (text: string, caretBack?: number) => void };
41
+
42
+ function computeRanges(path: Path, text: string): DecRange[] {
43
+ const out: DecRange[] = [];
44
+ const re = /"[^"]*"|#[A-Za-z0-9_]+|\b(?:near|within)(?:>)?(?:\/\d+)?\b|\b(?:and|or)\b|[^\s()"]+/gi;
45
+ let m: RegExpExecArray | null;
46
+ while ((m = re.exec(text))) {
47
+ const tok = m[0];
48
+ const start = m.index;
49
+ const end = start + tok.length;
50
+ const range = (mark: keyof CustomText): DecRange => ({ anchor: { path, offset: start }, focus: { path, offset: end }, [mark]: true });
51
+ if (tok[0] === '"') out.push(range('phrase'));
52
+ else if (tok[0] === '#') out.push(range('group'));
53
+ else if (/^(?:near|within)(?:>)?(?:\/\d+)?$/i.test(tok) || /^(?:and|or)$/i.test(tok)) out.push(range('op'));
54
+ else if (tok.includes('*') || tok.includes('~') || tok.startsWith('?')) out.push(range('special'));
55
+ }
56
+
57
+ return out;
58
+ }
59
+
60
+ const serialize = (nodes: Descendant[]) => nodes.map((n) => SlateNode.string(n)).join('');
61
+
62
+ export const SlateMatchEditor = forwardRef<SlateMatchEditorHandle, { value?: string; groups?: Group[]; onChange?: (s: string) => void }>(function SlateMatchEditor(
63
+ { value = '', groups = [], onChange },
64
+ ref
65
+ ) {
66
+ const editor = useMemo(() => withHistory(withReact(createEditor())), []);
67
+ const initial = useMemo<Descendant[]>(() => [{ type: 'line', children: [{ text: value }] }], []); // eslint-disable-line react-hooks/exhaustive-deps
68
+
69
+ const lastEmitted = useRef(value);
70
+ const programmatic = useRef(false);
71
+ const [target, setTarget] = useState<Range | null>(null);
72
+ const [search, setSearch] = useState('');
73
+ const [index, setIndex] = useState(0);
74
+
75
+ const items = useMemo(() => (target ? groups.filter((g) => g.name.toLowerCase().includes(search)).slice(0, 8) : []), [target, search, groups]);
76
+
77
+ const resetEditor = useCallback(
78
+ (text: string) => {
79
+ programmatic.current = true;
80
+ editor.children = [{ type: 'line', children: [{ text }] }];
81
+ const end = { path: [0, 0], offset: text.length };
82
+ editor.selection = { anchor: end, focus: end };
83
+ editor.onChange();
84
+ },
85
+ [editor]
86
+ );
87
+
88
+ // sync external value → editor (toolbar, steppers, rule switch)
89
+ useEffect(() => {
90
+ if (value !== lastEmitted.current) {
91
+ resetEditor(value);
92
+ lastEmitted.current = value;
93
+ }
94
+ }, [value, resetEditor]);
95
+
96
+ const insertGroup = (name: string) => {
97
+ if (!target) return;
98
+ Transforms.select(editor, target);
99
+ Transforms.insertText(editor, `#${name} `);
100
+ setTarget(null);
101
+ };
102
+
103
+ // toolbar pills / steppers insert at the current caret (or end if never focused)
104
+ useImperativeHandle(
105
+ ref,
106
+ () => ({
107
+ insert(text: string, caretBack = 0) {
108
+ ReactEditor.focus(editor);
109
+ if (!editor.selection) Transforms.select(editor, Editor.end(editor, []));
110
+ Transforms.insertText(editor, text);
111
+ if (caretBack > 0) Transforms.move(editor, { distance: caretBack, unit: 'character', reverse: true });
112
+ }
113
+ }),
114
+ [editor]
115
+ );
116
+
117
+ const handleChange = (val: Descendant[]) => {
118
+ const str = serialize(val);
119
+ lastEmitted.current = str;
120
+ onChange?.(str);
121
+ if (programmatic.current) {
122
+ programmatic.current = false;
123
+ setTarget(null);
124
+
125
+ return;
126
+ }
127
+ const { selection } = editor;
128
+ if (selection && Range.isCollapsed(selection)) {
129
+ const [start] = Range.edges(selection);
130
+ const before = Editor.string(editor, { anchor: Editor.start(editor, []), focus: start });
131
+ const mention = before.match(/#([A-Za-z0-9_]*)$/);
132
+ if (mention) {
133
+ setTarget({ anchor: { path: start.path, offset: start.offset - mention[0].length }, focus: start });
134
+ setSearch(mention[1].toLowerCase());
135
+ setIndex(0);
136
+
137
+ return;
138
+ }
139
+ }
140
+ setTarget(null);
141
+ };
142
+
143
+ const renderLeaf = useCallback((props: { leaf: CustomText; attributes: Record<string, unknown>; children: React.ReactNode }) => {
144
+ const { leaf, attributes, children } = props;
145
+ const cls = leaf.op
146
+ ? 'text-muted-foreground italic'
147
+ : leaf.group
148
+ ? 'font-medium text-blue-600 dark:text-blue-400'
149
+ : leaf.phrase
150
+ ? 'text-emerald-700 dark:text-emerald-400'
151
+ : leaf.special
152
+ ? 'text-amber-600 dark:text-amber-400'
153
+ : '';
154
+
155
+ return (
156
+ <span {...attributes} className={cls}>
157
+ {children}
158
+ </span>
159
+ );
160
+ }, []);
161
+
162
+ const decorate = useCallback(([node, path]: [SlateNode, Path]) => (Text.isText(node) ? computeRanges(path, node.text) : []), []);
163
+
164
+ return (
165
+ <div className='relative'>
166
+ <Slate editor={editor} initialValue={initial} onChange={handleChange}>
167
+ <div className='rounded-md border bg-background px-3 py-2 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring'>
168
+ <Editable
169
+ decorate={decorate}
170
+ renderLeaf={renderLeaf}
171
+ spellCheck={false}
172
+ placeholder='app near slow near #group'
173
+ className='font-mono text-sm outline-none'
174
+ onKeyDown={(e) => {
175
+ if (e.key === 'Enter') {
176
+ e.preventDefault();
177
+ if (target && items[index]) insertGroup(items[index].name);
178
+
179
+ return;
180
+ }
181
+ if (target && items.length) {
182
+ if (e.key === 'ArrowDown') {
183
+ e.preventDefault();
184
+ setIndex((i) => Math.min(items.length - 1, i + 1));
185
+ } else if (e.key === 'ArrowUp') {
186
+ e.preventDefault();
187
+ setIndex((i) => Math.max(0, i - 1));
188
+ } else if (e.key === 'Tab') {
189
+ e.preventDefault();
190
+ insertGroup(items[index].name);
191
+ } else if (e.key === 'Escape') {
192
+ e.preventDefault();
193
+ setTarget(null);
194
+ }
195
+ }
196
+ }}
197
+ />
198
+ </div>
199
+ </Slate>
200
+
201
+ {target && items.length > 0 && (
202
+ <div className='absolute left-0 top-full z-30 mt-1 max-h-56 w-72 overflow-auto rounded-md border bg-background p-1 shadow-md'>
203
+ {items.map((g, i) => (
204
+ <div
205
+ key={g.name}
206
+ onMouseDown={(e) => {
207
+ e.preventDefault();
208
+ insertGroup(g.name);
209
+ }}
210
+ className={`flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm ${i === index ? 'bg-accent' : 'hover:bg-accent'}`}>
211
+ <span className='font-mono text-blue-600 dark:text-blue-400'>#{g.name}</span>
212
+ <span className='ml-auto text-[11px] text-muted-foreground'>{g.terms.length} terms</span>
213
+ </div>
214
+ ))}
215
+ </div>
216
+ )}
217
+ </div>
218
+ );
219
+ });
220
+
221
+ // ═══════════════════════════════════════════════════════════════════════════
222
+ // RulesBuilder
223
+ // ═══════════════════════════════════════════════════════════════════════════
224
+
225
+ // ─── Data model ──────────────────────────────────────────────────────────────
226
+
227
+ type Rule = { id: string; match: string; exclude: string[] };
228
+ type Topic = { id: string; name: string; rules: Rule[] };
229
+ type Segment = { id: string; name: string; topics: Topic[] };
230
+ type WordGroup = { id: string; name: string; terms: string[] };
231
+ type Status = 'match' | 'excluded' | 'miss';
232
+ type Part = { text: string; norm: string; kind: Status | 'none' };
233
+ type Section = 'rules' | 'groups';
234
+ type RulesView = 'segments' | 'topics' | 'rules' | 'segForm' | 'topicForm';
235
+ type Confirm = { message: string; onYes: () => void; label?: string; tone?: 'danger' | 'primary' };
236
+
237
+ const ROW_CAP = 100;
238
+ const DEFAULT_DIST = 5;
239
+
240
+ // Seed examples for the test box. Editable/clearable — paste real comments to test against.
241
+ const SEED_TEST = [
242
+ 'The app is really slow when loading large files.',
243
+ 'The app feels slow and laggy, but the team is great.',
244
+ 'Constant crashes and freezes make the platform unusable.',
245
+ 'Support was fast and resolved my issue quickly.'
246
+ ].join('\n');
247
+
248
+ const INITIAL_SEGMENTS: Segment[] = [
249
+ { id: 's1', name: 'Customer Feedback', topics: ['Delivery', 'Pricing', 'Onboarding', 'Support'].map((name, i) => ({ id: `s1t${i}`, name, rules: [] })) },
250
+ {
251
+ id: 's2',
252
+ name: 'Product Experience',
253
+ topics: [
254
+ { id: 's2t0', name: 'Usability', rules: [] },
255
+ {
256
+ id: 's2t1',
257
+ name: 'Performance',
258
+ rules: [
259
+ { id: 'r1', match: 'app near slow near #speed_en', exclude: ['#positive_en', 'fast'] },
260
+ { id: 'r2', match: '(crash or freeze) near #app_en', exclude: [] },
261
+ { id: 'r3', match: 'load near/3 time', exclude: ['quick'] },
262
+ { id: 'r4', match: 'lag and stutter', exclude: ['smooth'] }
263
+ ]
264
+ },
265
+ { id: 's2t2', name: 'Reliability', rules: [] }
266
+ ]
267
+ },
268
+ { id: 's3', name: 'Support', topics: ['Response Time', 'Resolution'].map((name, i) => ({ id: `s3t${i}`, name, rules: [] })) },
269
+ { id: 's4', name: 'Billing', topics: ['Invoices', 'Refunds'].map((name, i) => ({ id: `s4t${i}`, name, rules: [] })) },
270
+ // Topics with no segment. The "Ungrouped" bucket is a UI grouping, not a real segment.
271
+ { id: 'none', name: 'Ungrouped', topics: ['General', 'Sensitive - Privacy', 'Sensitive - Accessibility', 'Admin - Account Management'].map((name, i) => ({ id: `nt${i}`, name, rules: [] })) }
272
+ ];
273
+
274
+ const INITIAL_WORD_GROUPS: WordGroup[] = [
275
+ { id: 'g1', name: 'speed_en', terms: ['slow', 'sluggish', 'lag', 'laggy', 'delay', 'delayed'] },
276
+ { id: 'g2', name: 'app_en', terms: ['app', 'application', 'software', 'platform', 'system'] },
277
+ { id: 'g3', name: 'positive_en', terms: ['great', 'good', 'love', 'excellent', 'awesome', 'fast'] },
278
+ { id: 'g4', name: 'staff_en', terms: ['agent', 'rep', 'support', 'staff', 'team'] }
279
+ ];
280
+
281
+ // Ungrouped topics often carry a category in their name ("Sensitive - Privacy", "Admin - Account Management").
282
+ // Split on " - " / " – " / " — " to derive a display-only prefix that can be promoted to a real segment.
283
+ const splitPrefix = (name: string): { prefix: string | null; rest: string } => {
284
+ const m = name.match(/^(.+?)\s+[-–—]\s+(.+)$/);
285
+
286
+ return m ? { prefix: m[1].trim(), rest: m[2].trim() } : { prefix: null, rest: name };
287
+ };
288
+
289
+ // ─── DSL engine: tokenize → parse → evaluate ─────────────────────────────────
290
+
291
+ type Ast =
292
+ | { k: 'term'; kind: 'word' | 'phrase' | 'group'; v: string }
293
+ | { k: 'and'; l: Ast; r: Ast }
294
+ | { k: 'or'; l: Ast; r: Ast }
295
+ | { k: 'near'; l: Ast; r: Ast; dist: number; ordered: boolean };
296
+ type Tok = { t: 'word' | 'phrase' | 'group' | 'op' | 'num' | 'lp' | 'rp'; v: string; ordered?: boolean };
297
+
298
+ const norm = (w: string) => w.toLowerCase().replace(/[^a-z]/g, '');
299
+ const expand = (tok: string, gm: Record<string, string[]>) => (tok.startsWith('#') ? (gm[tok.slice(1)] ?? [tok.slice(1)]) : [tok]);
300
+
301
+ function lev(a: string, b: string) {
302
+ const m = a.length;
303
+ const n = b.length;
304
+ if (!m) return n;
305
+ if (!n) return m;
306
+ const d = Array.from({ length: n + 1 }, (_, j) => j);
307
+ for (let i = 1; i <= m; i++) {
308
+ let prev = d[0];
309
+ d[0] = i;
310
+ for (let j = 1; j <= n; j++) {
311
+ const tmp = d[j];
312
+ d[j] = Math.min(d[j] + 1, d[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
313
+ prev = tmp;
314
+ }
315
+ }
316
+
317
+ return d[n];
318
+ }
319
+
320
+ /** Match a comment word against a rule term: wildcard (manage*), fuzzy (?word / word~[n]), stemming, exact. */
321
+ function matchTerm(word: string, term: string, stem: boolean) {
322
+ const w = norm(word);
323
+ if (!w) return false;
324
+ const tl = term.toLowerCase();
325
+ if (tl.includes('*')) {
326
+ const pat = tl.replace(/[^a-z*]/g, '').split('*').join('.*');
327
+
328
+ return new RegExp(`^${pat}$`).test(w);
329
+ }
330
+ const fz = tl.match(/^\?(.+)$/) ?? tl.match(/^(.+?)~(\d)?$/);
331
+ if (fz) {
332
+ const base = norm(fz[1]);
333
+ if (!base) return false;
334
+ const max = fz[2] ? parseInt(fz[2], 10) : base.length <= 4 ? 1 : 2;
335
+
336
+ return w === base || lev(w, base) <= max;
337
+ }
338
+ const t = norm(tl);
339
+ if (!t) return false;
340
+ if (w === t) return true;
341
+ if (stem) {
342
+ if (w.replace(/s$/, '') === t) return true;
343
+ if (t.length >= 4 && w.startsWith(t)) return true;
344
+ }
345
+
346
+ return false;
347
+ }
348
+
349
+ function tokenize(src: string): Tok[] {
350
+ const out: Tok[] = [];
351
+ let i = 0;
352
+ while (i < src.length) {
353
+ const c = src[i];
354
+ if (/\s/.test(c)) {
355
+ i++;
356
+ continue;
357
+ }
358
+ if (c === '(') {
359
+ out.push({ t: 'lp', v: '(' });
360
+ i++;
361
+ continue;
362
+ }
363
+ if (c === ')') {
364
+ out.push({ t: 'rp', v: ')' });
365
+ i++;
366
+ continue;
367
+ }
368
+ if (c === '"') {
369
+ let j = i + 1;
370
+ while (j < src.length && src[j] !== '"') j++;
371
+ out.push({ t: 'phrase', v: src.slice(i + 1, j) });
372
+ i = j + 1;
373
+ continue;
374
+ }
375
+ let j = i;
376
+ while (j < src.length && !/\s/.test(src[j]) && src[j] !== '(' && src[j] !== ')' && src[j] !== '"') j++;
377
+ const w = src.slice(i, j);
378
+ i = j;
379
+ const prox = w.match(/^(near|within)(>)?(?:\/(\d+))?$/i);
380
+ if (prox) {
381
+ out.push({ t: 'op', v: 'near', ordered: !!prox[2] });
382
+ if (prox[3]) out.push({ t: 'num', v: prox[3] });
383
+ continue;
384
+ }
385
+ const lw = w.toLowerCase();
386
+ if (lw === 'and' || lw === 'or') out.push({ t: 'op', v: lw });
387
+ else if (/^\d+$/.test(w)) out.push({ t: 'num', v: w });
388
+ else if (w.startsWith('#')) out.push({ t: 'group', v: w.slice(1) });
389
+ else out.push({ t: 'word', v: w });
390
+ }
391
+
392
+ return out;
393
+ }
394
+
395
+ function parse(toks: Tok[]): Ast {
396
+ let p = 0;
397
+ const peek = () => toks[p];
398
+ const err = (m: string) => new Error(m);
399
+
400
+ const parseAtom = (): Ast => {
401
+ const tk = peek();
402
+ if (!tk) throw err('Expression is incomplete');
403
+ if (tk.t === 'lp') {
404
+ p++;
405
+ const n = parseOr();
406
+ if (peek()?.t !== 'rp') throw err('Missing closing )');
407
+ p++;
408
+
409
+ return n;
410
+ }
411
+ if (tk.t === 'rp') throw err('Unexpected )');
412
+ if (tk.t === 'op') throw err(`"${tk.v}" needs a term before it`);
413
+ if (tk.t === 'num') throw err('Unexpected number');
414
+ p++;
415
+
416
+ return { k: 'term', kind: tk.t, v: tk.v };
417
+ };
418
+ const parseProx = (): Ast => {
419
+ let node = parseAtom();
420
+ while (peek()?.t === 'op' && peek().v === 'near') {
421
+ const op = toks[p++];
422
+ let dist = DEFAULT_DIST;
423
+ if (peek()?.t === 'num') dist = parseInt(toks[p++].v, 10);
424
+ node = { k: 'near', l: node, r: parseAtom(), dist, ordered: !!op.ordered };
425
+ }
426
+
427
+ return node;
428
+ };
429
+ const parseAnd = (): Ast => {
430
+ let node = parseProx();
431
+ while (peek()?.t === 'op' && peek().v === 'and') {
432
+ p++;
433
+ node = { k: 'and', l: node, r: parseProx() };
434
+ }
435
+
436
+ return node;
437
+ };
438
+ const parseOr = (): Ast => {
439
+ let node = parseAnd();
440
+ while (peek()?.t === 'op' && peek().v === 'or') {
441
+ p++;
442
+ node = { k: 'or', l: node, r: parseAnd() };
443
+ }
444
+
445
+ return node;
446
+ };
447
+
448
+ const ast = parseOr();
449
+ if (p < toks.length) throw err(`Unexpected "${toks[p].v}"`);
450
+
451
+ return ast;
452
+ }
453
+
454
+ function collectGroups(node: Ast): string[] {
455
+ if (node.k === 'term') return node.kind === 'group' ? [node.v] : [];
456
+
457
+ return node.k === 'near' || node.k === 'and' || node.k === 'or' ? [...collectGroups(node.l), ...collectGroups(node.r)] : [];
458
+ }
459
+
460
+ type Ev = { ok: boolean; pos: number[] };
461
+ function termPositions(node: Extract<Ast, { k: 'term' }>, words: string[], gm: Record<string, string[]>, stem: boolean): number[] {
462
+ const ps: number[] = [];
463
+ if (node.kind === 'group') {
464
+ const terms = gm[node.v] ?? [node.v];
465
+ words.forEach((w, i) => terms.some((t) => matchTerm(w, t, stem)) && ps.push(i));
466
+ } else if (node.kind === 'phrase') {
467
+ const seq = node.v.split(/\s+/).filter(Boolean);
468
+ for (let i = 0; i + seq.length <= words.length; i++) if (seq.every((s, k) => matchTerm(words[i + k], s, stem))) for (let k = 0; k < seq.length; k++) ps.push(i + k);
469
+ } else {
470
+ words.forEach((w, i) => matchTerm(w, node.v, stem) && ps.push(i));
471
+ }
472
+
473
+ return ps;
474
+ }
475
+
476
+ function evaluate(node: Ast, words: string[], gm: Record<string, string[]>, stem: boolean): Ev {
477
+ if (node.k === 'term') {
478
+ const pos = termPositions(node, words, gm, stem);
479
+
480
+ return { ok: pos.length > 0, pos };
481
+ }
482
+ if (node.k === 'or') {
483
+ const l = evaluate(node.l, words, gm, stem);
484
+ const r = evaluate(node.r, words, gm, stem);
485
+ if (l.ok && r.ok) return { ok: true, pos: [...l.pos, ...r.pos] };
486
+
487
+ return l.ok ? l : r.ok ? r : { ok: false, pos: [] };
488
+ }
489
+ if (node.k === 'and') {
490
+ const l = evaluate(node.l, words, gm, stem);
491
+ const r = evaluate(node.r, words, gm, stem);
492
+
493
+ return l.ok && r.ok ? { ok: true, pos: [...l.pos, ...r.pos] } : { ok: false, pos: [] };
494
+ }
495
+ // near (ordered → right must follow left)
496
+ const l = evaluate(node.l, words, gm, stem);
497
+ const r = evaluate(node.r, words, gm, stem);
498
+ if (!l.ok || !r.ok) return { ok: false, pos: [] };
499
+ const hit = new Set<number>();
500
+ let ok = false;
501
+ for (const a of l.pos)
502
+ for (const b of r.pos) {
503
+ const within = node.ordered ? b > a && b - a <= node.dist : Math.abs(a - b) <= node.dist;
504
+ if (within) {
505
+ ok = true;
506
+ hit.add(a);
507
+ hit.add(b);
508
+ }
509
+ }
510
+
511
+ return ok ? { ok: true, pos: [...hit] } : { ok: false, pos: [] };
512
+ }
513
+
514
+ function evalComment(text: string, ast: Ast | null, exclude: string[], gm: Record<string, string[]>, stem: boolean): { status: Status; parts: Part[] } {
515
+ const words = text.split(/\s+/);
516
+ const m = ast ? evaluate(ast, words, gm, stem) : { ok: false, pos: [] };
517
+ const exTerms = exclude.flatMap((t) => expand(t, gm));
518
+ const exPos = new Set<number>();
519
+ words.forEach((w, i) => exTerms.some((t) => matchTerm(w, t, stem)) && exPos.add(i));
520
+ const matchPos = new Set(m.ok ? m.pos : []);
521
+ const status: Status = m.ok && exPos.size ? 'excluded' : m.ok ? 'match' : 'miss';
522
+ const parts: Part[] = words.map((w, i) => ({ text: w, norm: norm(w), kind: exPos.has(i) ? 'excluded' : matchPos.has(i) ? 'match' : 'none' }));
523
+
524
+ return { status, parts };
525
+ }
526
+
527
+ // readable English + compiled-query preview from the AST
528
+ function flat(node: Ast, k: Ast['k']): Ast[] {
529
+ return node.k === k && 'l' in node ? [...flat((node as any).l, k), (node as any).r] : [node];
530
+ }
531
+ function english(node: Ast): string {
532
+ if (node.k === 'term') return node.kind === 'group' ? `any ${node.v} term` : `“${node.v}”`;
533
+ if (node.k === 'or') return flat(node, 'or').map((n) => (n.k === 'and' ? `(${english(n)})` : english(n))).join(' or ');
534
+ if (node.k === 'and') return flat(node, 'and').map((n) => (n.k === 'or' ? `(${english(n)})` : english(n))).join(' and ');
535
+
536
+ const parts = flat(node, 'near').map(english).join(` within ${(node as any).dist} words of `);
537
+
538
+ return (node as any).ordered ? `${parts} (in order)` : parts;
539
+ }
540
+ function ivl(node: Ast): any {
541
+ if (node.k === 'term') {
542
+ if (node.kind === 'word') {
543
+ const clean = node.v.toLowerCase();
544
+ if (clean.includes('*')) {
545
+ const pat = clean.replace(/[^a-z*]/g, '');
546
+
547
+ return /^[a-z]+\*$/.test(pat) ? { prefix: { prefix: pat.slice(0, -1), analyzer: 'word_groups' } } : { wildcard: { pattern: pat, analyzer: 'word_groups' } };
548
+ }
549
+ const fz = clean.match(/^\?(.+)$/) ?? clean.match(/^(.+?)~\d?$/);
550
+ if (fz) return { fuzzy: { term: norm(fz[1]), analyzer: 'word_groups' } };
551
+ }
552
+
553
+ return { match: { query: node.kind === 'group' ? `#${node.v}` : node.v, analyzer: 'word_groups' } };
554
+ }
555
+ if (node.k === 'near') return { all_of: { ordered: !!node.ordered, max_gaps: Math.max(0, node.dist - 1), intervals: flat(node, 'near').map(ivl) } };
556
+ if (node.k === 'and') return { all_of: { ordered: false, intervals: flat(node, 'and').map(ivl) } };
557
+
558
+ return { any_of: { intervals: flat(node, 'or').map(ivl) } };
559
+ }
560
+ function astToQuery(ast: Ast | null, exclude: string[]) {
561
+ if (!ast) return '—';
562
+ const q: any = { bool: { must: [{ intervals: { comment_text: ivl(ast) } }] } };
563
+ if (exclude.length) q.bool.must_not = [{ match: { comment_text: { query: exclude.join(' '), analyzer: 'word_groups' } } }];
564
+
565
+ return JSON.stringify(q, null, 2);
566
+ }
567
+
568
+ // ─── Presentational helpers ───────────────────────────────────────────────────
569
+
570
+ const btn = 'inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground';
571
+ const btnSm = 'inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs transition-colors hover:bg-accent';
572
+ const field = 'w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring focus:border-ring';
573
+
574
+ function ExprText({ text }: { text: string }) {
575
+ if (!text) return <span className='text-muted-foreground italic'>(empty rule)</span>;
576
+
577
+ return (
578
+ <>
579
+ {text.split(/(\s+)/).map((tk, i) => {
580
+ const lw = tk.toLowerCase();
581
+ if (tk.startsWith('#')) return <span key={i} className='font-medium text-blue-600 dark:text-blue-400'>{tk}</span>;
582
+ if (/^(near|within)(>)?(\/\d+)?$/.test(lw) || lw === 'and' || lw === 'or') return <span key={i} className='text-muted-foreground italic'>{tk}</span>;
583
+ if (tk.startsWith('"')) return <span key={i} className='text-emerald-700 dark:text-emerald-400'>{tk}</span>;
584
+ if (tk.includes('*') || tk.startsWith('?') || /~\d?$/.test(tk)) return <span key={i} className='text-amber-600 dark:text-amber-400'>{tk}</span>;
585
+
586
+ return <span key={i}>{tk}</span>;
587
+ })}
588
+ </>
589
+ );
590
+ }
591
+
592
+ function StatusIcon({ status }: { status: Status }) {
593
+ if (status === 'match') return <Check className='size-4 text-emerald-600 dark:text-emerald-400' />;
594
+ if (status === 'excluded') return <Ban className='size-4 text-red-600 dark:text-red-400' />;
595
+
596
+ return <Minus className='size-4 text-muted-foreground' />;
597
+ }
598
+
599
+ function Crumb({ label, items, onSelect, accent }: { label: string; items: { id: string; label: string }[]; onSelect: (id: string) => void; accent?: boolean }) {
600
+ const [open, setOpen] = useState(false);
601
+ const [q, setQ] = useState('');
602
+ const shown = q ? items.filter((i) => i.label.toLowerCase().includes(q.toLowerCase())) : items;
603
+
604
+ return (
605
+ <span className='relative inline-flex'>
606
+ <button onClick={() => setOpen((o) => !o)} className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 transition-colors ${accent ? 'bg-primary/10 text-primary' : 'bg-muted hover:bg-accent'}`}>
607
+ <span className='max-w-[200px] truncate'>{label}</span>
608
+ <ChevronDown className='size-3.5 opacity-60' />
609
+ </button>
610
+ {open && (
611
+ <>
612
+ <div className='fixed inset-0 z-10' onClick={() => setOpen(false)} />
613
+ <div className='absolute left-0 top-full z-20 mt-1 max-h-72 w-64 overflow-auto rounded-md border bg-background p-1 shadow-md'>
614
+ {items.length > 8 && <input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder='Filter…' className='mb-1 w-full rounded-md border bg-background px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-ring' />}
615
+ {shown.map((it) => (
616
+ <button key={it.id} onClick={() => { onSelect(it.id); setOpen(false); setQ(''); }} className='block w-full truncate rounded px-2 py-1.5 text-left text-sm hover:bg-accent'>
617
+ {it.label}
618
+ </button>
619
+ ))}
620
+ {shown.length === 0 && <div className='px-2 py-1.5 text-sm text-muted-foreground'>No matches</div>}
621
+ </div>
622
+ </>
623
+ )}
624
+ </span>
625
+ );
626
+ }
627
+
628
+ // ─── Component ────────────────────────────────────────────────────────────────
629
+
630
+ export default function RulesBuilder() {
631
+ const [segments, setSegments] = useState<Segment[]>(INITIAL_SEGMENTS);
632
+ const [groups, setGroups] = useState<WordGroup[]>(INITIAL_WORD_GROUPS);
633
+
634
+ const [section, setSection] = useState<Section>('rules');
635
+ const [rv, setRv] = useState<RulesView>('rules');
636
+ const [returnRv, setReturnRv] = useState<RulesView>('rules');
637
+ const [openSegs, setOpenSegs] = useState<Record<string, boolean>>({ s2: true });
638
+ const [navFilter, setNavFilter] = useState('');
639
+
640
+ const [selSeg, setSelSeg] = useState('s2');
641
+ const [selTopic, setSelTopic] = useState('s2t1');
642
+ const [selRule, setSelRule] = useState('r1');
643
+
644
+ const [segForm, setSegForm] = useState<{ id: string | null; name: string }>({ id: null, name: '' });
645
+ const [topicForm, setTopicForm] = useState<{ id: string | null; segId: string; name: string }>({ id: null, segId: '', name: '' });
646
+ const [confirm, setConfirm] = useState<Confirm | null>(null);
647
+
648
+ const [match, setMatch] = useState('app near slow near #speed_en');
649
+ const [exclude, setExclude] = useState<string[]>(['#positive_en', 'fast']);
650
+ const [exInput, setExInput] = useState('');
651
+ const [exIndex, setExIndex] = useState(0);
652
+ const [exFocus, setExFocus] = useState(false);
653
+ const [showQuery, setShowQuery] = useState(false);
654
+ const [stem, setStem] = useState(true);
655
+ const [testInput, setTestInput] = useState(SEED_TEST);
656
+ const [ruleSearch, setRuleSearch] = useState('');
657
+ const [fHasExclude, setFHasExclude] = useState(false);
658
+ const [fUsesGroup, setFUsesGroup] = useState(false);
659
+ const editorRef = useRef<SlateMatchEditorHandle>(null);
660
+
661
+ const [selGroup, setSelGroup] = useState<string | null>('g1');
662
+ const [gName, setGName] = useState(INITIAL_WORD_GROUPS[0].name);
663
+ const [gTerms, setGTerms] = useState<string[]>([...INITIAL_WORD_GROUPS[0].terms]);
664
+ const [gInput, setGInput] = useState('');
665
+ const [gTermFilter, setGTermFilter] = useState('');
666
+ const [gIsNew, setGIsNew] = useState(false);
667
+ const [groupSearch, setGroupSearch] = useState('');
668
+
669
+ const idRef = useRef(1000);
670
+ const nextId = (p: string) => `${p}${idRef.current++}`;
671
+ const ask = (message: string, onYes: () => void, opts?: { label?: string; tone?: 'danger' | 'primary' }) => setConfirm({ message, onYes, ...opts });
672
+
673
+ const groupMap = useMemo(() => Object.fromEntries(groups.map((g) => [g.name, g.terms])), [groups]);
674
+ const usage = (name: string) => segments.reduce((n, s) => n + s.topics.reduce((m, t) => m + t.rules.filter((r) => r.match.includes(`#${name}`) || r.exclude.includes(`#${name}`)).length, 0), 0);
675
+ const ruleCount = (s: Segment) => s.topics.reduce((n, t) => n + t.rules.length, 0);
676
+
677
+ const seg = segments.find((s) => s.id === selSeg);
678
+ const topic = seg?.topics.find((t) => t.id === selTopic);
679
+ const rules = topic?.rules ?? [];
680
+
681
+ // parse the current expression once
682
+ const parsed = useMemo<{ ast: Ast | null; error: string | null; warn: string | null }>(() => {
683
+ const m = match.trim();
684
+ if (!m) return { ast: null, error: null, warn: null };
685
+ try {
686
+ const ast = parse(tokenize(m));
687
+ const missing = collectGroups(ast).find((g) => !groupMap[g]);
688
+
689
+ return { ast, error: null, warn: missing ? `Unknown word group: #${missing}` : null };
690
+ } catch (e) {
691
+ return { ast: null, error: (e as Error).message, warn: null };
692
+ }
693
+ }, [match, groupMap]);
694
+
695
+ const samples = useMemo(() => testInput.split('\n').map((s) => s.trim()).filter(Boolean), [testInput]);
696
+ const results = useMemo(() => {
697
+ if (!parsed.ast) return [];
698
+ const order: Record<Status, number> = { match: 0, excluded: 1, miss: 2 };
699
+
700
+ return samples.map((s) => ({ s, ...evalComment(s, parsed.ast, exclude, groupMap, stem) })).sort((a, b) => order[a.status] - order[b.status]);
701
+ }, [parsed.ast, exclude, groupMap, stem, samples]);
702
+ const matchedCount = results.filter((r) => r.status === 'match').length;
703
+ const excludedCount = results.filter((r) => r.status === 'excluded').length;
704
+
705
+ // distance steppers — one per near/within occurrence in the text
706
+ const nearOccurrences = useMemo(() => [...match.matchAll(/\b(near|within)\b(>)?(?:\/(\d+))?/gi)].map((m) => ({ ordered: !!m[2], dist: m[3] ? parseInt(m[3], 10) : DEFAULT_DIST })), [match]);
707
+ const rewriteProx = (occIdx: number, patch: { dist?: number; ordered?: boolean }) => {
708
+ let k = -1;
709
+ setMatch(
710
+ match.replace(/\b(near|within)\b(>)?(?:\/(\d+))?/gi, (full, op, gt, num) => {
711
+ if (++k !== occIdx) return full;
712
+ const ordered = patch.ordered ?? !!gt;
713
+ const dist = patch.dist ?? (num ? parseInt(num, 10) : DEFAULT_DIST);
714
+
715
+ return `${op.toLowerCase()}${ordered ? '>' : ''}/${dist}`;
716
+ })
717
+ );
718
+ };
719
+ // toolbar pills / steppers insert into the Slate editor at the caret; # autocomplete lives in the editor
720
+ const insert = (token: string, caretBack = 0) => editorRef.current?.insert(token, caretBack);
721
+
722
+ const visibleRules = useMemo(() => {
723
+ const q = ruleSearch.toLowerCase();
724
+
725
+ return rules.filter((r) => {
726
+ if (q && !r.match.toLowerCase().includes(q) && !r.exclude.some((e) => e.toLowerCase().includes(q))) return false;
727
+ if (fHasExclude && r.exclude.length === 0) return false;
728
+ if (fUsesGroup && !r.match.includes('#') && !r.exclude.some((e) => e.startsWith('#'))) return false;
729
+
730
+ return true;
731
+ });
732
+ }, [rules, ruleSearch, fHasExclude, fUsesGroup]);
733
+
734
+ const visibleGroups = useMemo(() => {
735
+ const q = groupSearch.toLowerCase();
736
+
737
+ return groups.map((g) => ({ g, hitTerm: q && !g.name.includes(q) ? g.terms.find((t) => t.includes(q)) : undefined })).filter(({ g }) => !q || g.name.includes(q) || g.terms.some((t) => t.includes(q)));
738
+ }, [groups, groupSearch]);
739
+
740
+ // ── selection ──
741
+ const loadRule = (r?: Rule) => {
742
+ setMatch(r?.match ?? '');
743
+ setExclude(r ? [...r.exclude] : []);
744
+ };
745
+ const pickTopic = (segId: string, topicId: string) => {
746
+ setSection('rules');
747
+ setRv('rules');
748
+ setSelSeg(segId);
749
+ setSelTopic(topicId);
750
+ setRuleSearch('');
751
+ const first = segments.find((s) => s.id === segId)?.topics.find((t) => t.id === topicId)?.rules[0];
752
+ setSelRule(first?.id ?? '');
753
+ loadRule(first);
754
+ };
755
+ const switchSegment = (segId: string) => {
756
+ setSelSeg(segId);
757
+ setRv('topics');
758
+ };
759
+ const pickRule = (r: Rule) => {
760
+ setSelRule(r.id);
761
+ loadRule(r);
762
+ };
763
+
764
+ // ── rule CRUD ──
765
+ const mutateTopic = (fn: (t: Topic) => Topic) => setSegments((prev) => prev.map((s) => (s.id !== selSeg ? s : { ...s, topics: s.topics.map((t) => (t.id !== selTopic ? t : fn(t))) })));
766
+ const newRule = () => {
767
+ if (!topic) return;
768
+ const r: Rule = { id: nextId('r'), match: '', exclude: [] };
769
+ mutateTopic((t) => ({ ...t, rules: [...t.rules, r] }));
770
+ setSelRule(r.id);
771
+ loadRule(r);
772
+ };
773
+ const saveRule = () => {
774
+ if (!selRule) return;
775
+ mutateTopic((t) => ({ ...t, rules: t.rules.map((r) => (r.id === selRule ? { ...r, match, exclude: [...exclude] } : r)) }));
776
+ };
777
+ const deleteRule = (id: string) =>
778
+ ask('Delete this rule?', () => {
779
+ const remaining = rules.filter((r) => r.id !== id);
780
+ mutateTopic((t) => ({ ...t, rules: t.rules.filter((r) => r.id !== id) }));
781
+ if (selRule === id) {
782
+ setSelRule(remaining[0]?.id ?? '');
783
+ loadRule(remaining[0]);
784
+ }
785
+ });
786
+ const addExclude = (t: string) => {
787
+ const v = t.trim().replace(/,$/, '');
788
+ if (v && !exclude.includes(v)) setExclude([...exclude, v]);
789
+ setExInput('');
790
+ setExIndex(0);
791
+ };
792
+ // exclude-field helpers: # autocomplete (parity with the match field) + unknown-group warning
793
+ const exItems = exInput.startsWith('#') ? groups.filter((g) => g.name.toLowerCase().includes(exInput.slice(1).toLowerCase())).slice(0, 8) : [];
794
+ const unknownExcludes = exclude.filter((e) => e.startsWith('#') && !groupMap[e.slice(1)]).map((e) => e.slice(1));
795
+
796
+ // ── segment / topic CRUD ──
797
+ const openNewSegment = () => {
798
+ setSection('rules');
799
+ setReturnRv(rv);
800
+ setSegForm({ id: null, name: '' });
801
+ setRv('segForm');
802
+ };
803
+ const openEditSegment = (s: Segment) => {
804
+ setSection('rules');
805
+ setReturnRv(rv);
806
+ setSegForm({ id: s.id, name: s.name });
807
+ setRv('segForm');
808
+ };
809
+ const saveSegForm = () => {
810
+ const name = segForm.name.trim();
811
+ if (!name) return;
812
+ if (segForm.id) {
813
+ setSegments((prev) => prev.map((s) => (s.id === segForm.id ? { ...s, name } : s)));
814
+ setRv(returnRv);
815
+ } else {
816
+ const s: Segment = { id: nextId('s'), name, topics: [] };
817
+ setSegments((prev) => [...prev, s]);
818
+ setSelSeg(s.id);
819
+ setRv('topics');
820
+ }
821
+ };
822
+ const deleteSegment = (id: string) =>
823
+ ask('Delete this segment and all its topics and rules?', () => {
824
+ setSegments((prev) => prev.filter((s) => s.id !== id));
825
+ if (selSeg === id) setRv('segments');
826
+ });
827
+
828
+ const openNewTopic = (segId: string) => {
829
+ setSection('rules');
830
+ setReturnRv(rv);
831
+ setTopicForm({ id: null, segId, name: '' });
832
+ setRv('topicForm');
833
+ };
834
+ const openEditTopic = (segId: string, t: Topic) => {
835
+ setSection('rules');
836
+ setReturnRv(rv);
837
+ setTopicForm({ id: t.id, segId, name: t.name });
838
+ setRv('topicForm');
839
+ };
840
+ const saveTopicForm = () => {
841
+ const name = topicForm.name.trim();
842
+ if (!name || !topicForm.segId) return;
843
+ if (topicForm.id) {
844
+ const cur = segments.find((s) => s.topics.some((t) => t.id === topicForm.id));
845
+ const theTopic = cur?.topics.find((t) => t.id === topicForm.id);
846
+ if (cur && theTopic && cur.id !== topicForm.segId) {
847
+ setSegments((prev) =>
848
+ prev.map((s) => {
849
+ if (s.id === cur.id) return { ...s, topics: s.topics.filter((t) => t.id !== topicForm.id) };
850
+ if (s.id === topicForm.segId) return { ...s, topics: [...s.topics, { ...theTopic, name }] };
851
+
852
+ return s;
853
+ })
854
+ );
855
+ } else {
856
+ setSegments((prev) => prev.map((s) => (s.id === topicForm.segId ? { ...s, topics: s.topics.map((t) => (t.id === topicForm.id ? { ...t, name } : t)) } : s)));
857
+ }
858
+ pickTopic(topicForm.segId, topicForm.id);
859
+ } else {
860
+ const t: Topic = { id: nextId('t'), name, rules: [] };
861
+ setSegments((prev) => prev.map((s) => (s.id === topicForm.segId ? { ...s, topics: [...s.topics, t] } : s)));
862
+ pickTopic(topicForm.segId, t.id);
863
+ }
864
+ };
865
+ const deleteTopic = (segId: string, topicId: string) =>
866
+ ask('Delete this topic and its rules?', () => {
867
+ setSegments((prev) => prev.map((s) => (s.id === segId ? { ...s, topics: s.topics.filter((t) => t.id !== topicId) } : s)));
868
+ if (selTopic === topicId) setRv('topics');
869
+ });
870
+
871
+ // Promote a derived prefix in the Ungrouped bucket into a real segment (or merge into an existing
872
+ // same-named one). Topics move out of Ungrouped and lose the now-redundant prefix from their names.
873
+ const promotePrefix = (prefix: string, topics: Topic[], existingId?: string) => {
874
+ const ids = new Set(topics.map((t) => t.id));
875
+ const moved = topics.map((t) => ({ ...t, name: splitPrefix(t.name).rest }));
876
+ const targetId = existingId ?? nextId('s');
877
+ setSegments((prev) => {
878
+ const stripped = prev.map((s) => (s.id === 'none' ? { ...s, topics: s.topics.filter((t) => !ids.has(t.id)) } : s));
879
+ if (existingId) return stripped.map((s) => (s.id === existingId ? { ...s, topics: [...s.topics, ...moved] } : s));
880
+ const noneIdx = stripped.findIndex((s) => s.id === 'none');
881
+ const newSeg: Segment = { id: targetId, name: prefix, topics: moved };
882
+
883
+ return noneIdx < 0 ? [...stripped, newSeg] : [...stripped.slice(0, noneIdx), newSeg, ...stripped.slice(noneIdx)];
884
+ });
885
+ setOpenSegs((o) => ({ ...o, [targetId]: true }));
886
+ if (ids.has(selTopic)) setSelSeg(targetId);
887
+ };
888
+ const askPromote = (prefix: string, topics: Topic[], existing?: Segment) =>
889
+ ask(
890
+ existing
891
+ ? `Move ${topics.length} topic${topics.length > 1 ? 's' : ''} into the existing “${existing.name}” segment? The “${prefix} – ” prefix is removed from their names.`
892
+ : `Create a new “${prefix}” segment from ${topics.length} topic${topics.length > 1 ? 's' : ''}? The “${prefix} – ” prefix is removed from their names.`,
893
+ () => promotePrefix(prefix, topics, existing?.id),
894
+ { label: existing ? 'Move' : 'Promote', tone: 'primary' }
895
+ );
896
+
897
+ // ── word-group CRUD ──
898
+ const loadGroup = (g: WordGroup) => {
899
+ setSelGroup(g.id);
900
+ setGName(g.name);
901
+ setGTerms([...g.terms]);
902
+ setGInput('');
903
+ setGTermFilter('');
904
+ setGIsNew(false);
905
+ };
906
+ const newGroup = () => {
907
+ setSelGroup('__new');
908
+ setGName('');
909
+ setGTerms([]);
910
+ setGInput('');
911
+ setGTermFilter('');
912
+ setGIsNew(true);
913
+ };
914
+ const saveGroup = () => {
915
+ const name = gName.trim().replace(/^#/, '');
916
+ if (!name) return;
917
+ if (gIsNew) {
918
+ const g: WordGroup = { id: nextId('g'), name, terms: gTerms };
919
+ setGroups((prev) => [...prev, g]);
920
+ setSelGroup(g.id);
921
+ setGIsNew(false);
922
+ } else {
923
+ setGroups((prev) => prev.map((x) => (x.id === selGroup ? { ...x, name, terms: gTerms } : x)));
924
+ }
925
+ };
926
+ const deleteGroup = (g: WordGroup) => {
927
+ const n = usage(g.name);
928
+ ask(n ? `#${g.name} is used by ${n} rule(s). Delete anyway?` : `Delete #${g.name}?`, () => {
929
+ const remaining = groups.filter((x) => x.id !== g.id);
930
+ setGroups(remaining);
931
+ if (selGroup === g.id) {
932
+ if (remaining[0]) loadGroup(remaining[0]);
933
+ else {
934
+ setSelGroup(null);
935
+ setGTerms([]);
936
+ setGName('');
937
+ }
938
+ }
939
+ });
940
+ };
941
+ const addGroupTerms = (raw: string) => {
942
+ const parts = raw.split(/[,\n;]+/).map(norm).filter(Boolean);
943
+ if (parts.length) setGTerms((prev) => [...new Set([...prev, ...parts])]);
944
+ setGInput('');
945
+ };
946
+
947
+ const opBtn = 'rounded-md border px-2.5 py-1 font-mono text-xs transition-colors hover:bg-accent';
948
+
949
+ // one topic row in the tree (shared by normal segments and the Ungrouped prefix sub-lists)
950
+ const topicRow = (s: Segment, tp: Topic, displayName: string, pad: string) => {
951
+ const on = section === 'rules' && rv === 'rules' && s.id === selSeg && tp.id === selTopic;
952
+
953
+ return (
954
+ <div key={tp.id} className={`group flex items-center gap-2 rounded-md py-1.5 ${pad} pr-2 text-sm transition-colors ${on ? 'bg-primary/10 font-medium text-primary' : 'hover:bg-accent'}`}>
955
+ <button onClick={() => pickTopic(s.id, tp.id)} className='flex min-w-0 flex-1 items-center gap-2'>
956
+ <Tag className='size-3.5 opacity-70' />
957
+ <span className='flex-1 truncate text-left'>{displayName}</span>
958
+ </button>
959
+ <span className='flex items-center gap-1 opacity-0 group-hover:opacity-100'>
960
+ <button onClick={() => openEditTopic(s.id, tp)} title='Rename'><Pencil className='size-3.5 text-muted-foreground hover:text-foreground' /></button>
961
+ <button onClick={() => deleteTopic(s.id, tp.id)} title='Delete topic'><Trash2 className='size-3.5 text-muted-foreground hover:text-red-600' /></button>
962
+ </span>
963
+ {tp.rules.length > 0 && <span className='text-xs text-muted-foreground'>{tp.rules.length}</span>}
964
+ </div>
965
+ );
966
+ };
967
+
968
+ // Ungrouped bucket: group topics by derived name-prefix, each promotable to a real segment.
969
+ const renderUngrouped = (s: Segment, tps: Topic[]) => {
970
+ const byPrefix = new Map<string, Topic[]>();
971
+ const plain: Topic[] = [];
972
+ tps.forEach((t) => {
973
+ const { prefix } = splitPrefix(t.name);
974
+ if (!prefix) return void plain.push(t);
975
+ const arr = byPrefix.get(prefix);
976
+ if (arr) arr.push(t);
977
+ else byPrefix.set(prefix, [t]);
978
+ });
979
+
980
+ return (
981
+ <>
982
+ {[...byPrefix.entries()].map(([prefix, topics]) => {
983
+ const existing = segments.find((x) => x.id !== 'none' && x.name.toLowerCase() === prefix.toLowerCase());
984
+
985
+ return (
986
+ <div key={prefix}>
987
+ <div className='group flex items-center gap-1.5 rounded-md py-1 pl-7 pr-2'>
988
+ <span className='flex-1 truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground'>{prefix}</span>
989
+ <span className='text-[10px] text-muted-foreground'>{topics.length}</span>
990
+ <button
991
+ onClick={() => askPromote(prefix, topics, existing)}
992
+ title={existing ? `Move into the existing “${existing.name}” segment` : `Promote “${prefix}” to a segment`}
993
+ className='flex items-center gap-0.5 rounded px-1 text-[10px] text-muted-foreground opacity-0 transition-opacity hover:text-primary group-hover:opacity-100'>
994
+ <ArrowUpRight className='size-3' /> {existing ? 'merge' : 'promote'}
995
+ </button>
996
+ </div>
997
+ {topics.map((tp) => topicRow(s, tp, splitPrefix(tp.name).rest, 'pl-10'))}
998
+ </div>
999
+ );
1000
+ })}
1001
+ {plain.map((tp) => topicRow(s, tp, tp.name, 'pl-7'))}
1002
+ </>
1003
+ );
1004
+ };
1005
+
1006
+ return (
1007
+ <div className='mx-auto max-w-7xl p-4 text-foreground'>
1008
+ <div className='flex items-start gap-4'>
1009
+ <nav className='w-60 shrink-0 rounded-xl bg-muted/50 p-2.5'>
1010
+ <div className='relative mb-2.5'>
1011
+ <Search className='absolute left-2.5 top-2.5 size-4 text-muted-foreground' />
1012
+ <input className={`${field} h-9 pl-8`} placeholder='Search topics' value={navFilter} onChange={(e) => setNavFilter(e.target.value.toLowerCase().trim())} />
1013
+ </div>
1014
+ <button onClick={() => setSection('groups')} className={`mb-2 flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-sm transition-colors ${section === 'groups' ? 'bg-primary/10 font-medium text-primary' : 'hover:bg-accent'}`}>
1015
+ <BookText className='size-4' /> Word groups
1016
+ <span className='ml-auto text-xs text-muted-foreground'>{groups.length}</span>
1017
+ </button>
1018
+ <div className='my-2 border-t' />
1019
+ <div className='flex items-center justify-between px-1.5 pb-1.5'>
1020
+ <span className='text-[11px] uppercase tracking-wide text-muted-foreground'>Segments</span>
1021
+ <button onClick={openNewSegment} title='New segment'><Plus className='size-3.5 text-muted-foreground hover:text-foreground' /></button>
1022
+ </div>
1023
+ <div className='max-h-[600px] space-y-0.5 overflow-y-auto'>
1024
+ {segments.map((s) => {
1025
+ const tps = s.topics.filter((t) => !navFilter || t.name.toLowerCase().includes(navFilter) || s.name.toLowerCase().includes(navFilter));
1026
+ if (navFilter && tps.length === 0) return null;
1027
+ const open = openSegs[s.id] || (!!navFilter && tps.length > 0);
1028
+
1029
+ return (
1030
+ <div key={s.id}>
1031
+ <div className='group flex items-center gap-1.5 rounded-md px-1.5 py-1.5 text-sm font-medium hover:bg-accent'>
1032
+ <button onClick={() => setOpenSegs((o) => ({ ...o, [s.id]: !open }))} className='flex min-w-0 flex-1 items-center gap-1.5'>
1033
+ {open ? <ChevronDown className='size-3.5 text-muted-foreground' /> : <ChevronRight className='size-3.5 text-muted-foreground' />}
1034
+ <Folder className='size-4 text-muted-foreground' />
1035
+ <span className='flex-1 truncate text-left'>{s.name}</span>
1036
+ </button>
1037
+ <span className='flex items-center gap-1 opacity-0 group-hover:opacity-100'>
1038
+ {s.id !== 'none' && <button onClick={() => openEditSegment(s)} title='Rename'><Pencil className='size-3.5 text-muted-foreground hover:text-foreground' /></button>}
1039
+ <button onClick={() => openNewTopic(s.id)} title='Add topic'><Plus className='size-3.5 text-muted-foreground hover:text-foreground' /></button>
1040
+ {s.id !== 'none' && <button onClick={() => deleteSegment(s.id)} title='Delete segment'><Trash2 className='size-3.5 text-muted-foreground hover:text-red-600' /></button>}
1041
+ </span>
1042
+ <span className='text-xs text-muted-foreground'>{s.topics.length}</span>
1043
+ </div>
1044
+ {open && (s.id === 'none' ? renderUngrouped(s, tps) : tps.map((tp) => topicRow(s, tp, tp.name, 'pl-7')))}
1045
+ </div>
1046
+ );
1047
+ })}
1048
+ </div>
1049
+ </nav>
1050
+ <section className='min-w-0 flex-1'>
1051
+
1052
+ {section === 'groups' ? (
1053
+ <div className='flex items-start gap-4'>
1054
+ <div className='w-72 shrink-0'>
1055
+ <div className='mb-2 flex items-center justify-between'>
1056
+ <span className='text-sm font-medium'>{groups.length} word groups</span>
1057
+ <button onClick={newGroup} className={btnSm}><Plus className='size-3.5' /> New</button>
1058
+ </div>
1059
+ <div className='relative mb-2'>
1060
+ <Search className='absolute left-2.5 top-2 size-4 text-muted-foreground' />
1061
+ <input className={`${field} h-9 pl-8`} placeholder='Search name or term' value={groupSearch} onChange={(e) => setGroupSearch(e.target.value.toLowerCase().trim())} />
1062
+ </div>
1063
+ <div className='overflow-hidden rounded-md border'>
1064
+ {visibleGroups.slice(0, ROW_CAP).map(({ g, hitTerm }) => (
1065
+ <button key={g.id} onClick={() => loadGroup(g)} className={`block w-full border-b px-3 py-2 text-left last:border-b-0 transition-colors ${selGroup === g.id ? 'bg-primary/10' : 'hover:bg-accent/50'}`}>
1066
+ <div className='truncate font-mono text-[12.5px] text-blue-600 dark:text-blue-400'>#{g.name}</div>
1067
+ <div className='text-[11px] text-muted-foreground'>
1068
+ {g.terms.length} terms · used {usage(g.name)}
1069
+ {hitTerm && <span className='ml-1 text-emerald-600 dark:text-emerald-400'>· contains “{hitTerm}”</span>}
1070
+ </div>
1071
+ </button>
1072
+ ))}
1073
+ {visibleGroups.length === 0 && <div className='px-3 py-6 text-center text-sm text-muted-foreground'>No groups match.</div>}
1074
+ </div>
1075
+ {visibleGroups.length > ROW_CAP && <div className='py-1.5 text-center text-[11px] text-muted-foreground'>{visibleGroups.length - ROW_CAP} more · refine search</div>}
1076
+ </div>
1077
+
1078
+ <div className='min-w-0 flex-1'>
1079
+ {selGroup ? (
1080
+ <div className='rounded-xl border-2 border-primary/60 p-4'>
1081
+ <h3 className='mb-3 text-sm font-medium'>{gIsNew ? 'New word group' : 'Edit word group'}</h3>
1082
+ <label className='text-[11px] uppercase tracking-wide text-muted-foreground'>Name</label>
1083
+ <div className='mt-1.5 flex items-center gap-1.5'>
1084
+ <span className='font-mono text-sm text-blue-600 dark:text-blue-400'>#</span>
1085
+ <input className={`${field} font-mono`} placeholder='bp_topic_en' value={gName} onChange={(e) => setGName(e.target.value)} />
1086
+ </div>
1087
+ <div className='mt-4 flex items-center justify-between'>
1088
+ <label className='text-[11px] uppercase tracking-wide text-muted-foreground'>Terms ({gTerms.length})</label>
1089
+ {gTerms.length > 20 && <input className='h-7 w-40 rounded-md border bg-background px-2 text-xs outline-none focus:ring-2 focus:ring-ring' placeholder='filter terms' value={gTermFilter} onChange={(e) => setGTermFilter(e.target.value.toLowerCase())} />}
1090
+ </div>
1091
+ <div className='mt-1.5 flex flex-wrap items-center gap-1.5'>
1092
+ {gTerms.filter((t) => !gTermFilter || t.includes(gTermFilter)).map((t) => (
1093
+ <span key={t} className='inline-flex items-center gap-1 rounded-md bg-muted px-2.5 py-1 text-sm'>
1094
+ {t}
1095
+ <X className='size-3 cursor-pointer' onClick={() => setGTerms(gTerms.filter((x) => x !== t))} />
1096
+ </span>
1097
+ ))}
1098
+ <input className='h-7 w-48 rounded-md border bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-ring' placeholder='+ add term(s), comma to bulk-add' value={gInput} onChange={(e) => setGInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addGroupTerms(gInput); }} />
1099
+ </div>
1100
+ {!gIsNew && <p className='mt-3 text-xs text-muted-foreground'>Used by {usage(gName)} rules — saving updates all of them.</p>}
1101
+ <div className='mt-4 flex justify-end gap-2'>
1102
+ {!gIsNew && selGroup && <button onClick={() => deleteGroup(groups.find((x) => x.id === selGroup)!)} className={`${btn} text-red-600 hover:text-red-600`}><Trash2 className='size-4' /> Delete</button>}
1103
+ <button onClick={saveGroup} disabled={!gName.trim()} className={`${btn} border-primary text-primary disabled:pointer-events-none disabled:opacity-50`}>Save</button>
1104
+ </div>
1105
+ </div>
1106
+ ) : (
1107
+ <div className='rounded-xl bg-muted/50 p-8 text-center text-sm text-muted-foreground'>Select a word group, or create a new one.</div>
1108
+ )}
1109
+ </div>
1110
+ </div>
1111
+ ) : (
1112
+ <>
1113
+ <div className='mb-4 flex flex-wrap items-center gap-2 text-sm text-muted-foreground'>
1114
+ <button onClick={() => setRv('segments')} className='font-medium text-foreground hover:underline'>Rules</button>
1115
+ {(rv === 'topics' || rv === 'rules') && seg && (
1116
+ <>
1117
+ <ChevronRight className='size-3.5' />
1118
+ <Crumb label={seg.name} items={segments.map((s) => ({ id: s.id, label: s.name }))} onSelect={switchSegment} accent={rv === 'topics'} />
1119
+ </>
1120
+ )}
1121
+ {rv === 'rules' && topic && seg && (
1122
+ <>
1123
+ <ChevronRight className='size-3.5' />
1124
+ <Crumb label={topic.name} items={seg.topics.map((t) => ({ id: t.id, label: t.name }))} onSelect={(id) => pickTopic(seg.id, id)} accent />
1125
+ </>
1126
+ )}
1127
+ {rv === 'segForm' && <><ChevronRight className='size-3.5' /><span className='text-foreground'>{segForm.id ? 'Edit segment' : 'New segment'}</span></>}
1128
+ {rv === 'topicForm' && <><ChevronRight className='size-3.5' /><span className='text-foreground'>{topicForm.id ? 'Edit topic' : 'New topic'}</span></>}
1129
+ </div>
1130
+
1131
+ {rv === 'segments' && (
1132
+ <div className='max-w-2xl'>
1133
+ <div className='mb-3 flex items-center justify-between'>
1134
+ <h2 className='text-lg font-medium'>Segments</h2>
1135
+ <button onClick={openNewSegment} className={btn}><Plus className='size-4' /> New segment</button>
1136
+ </div>
1137
+ <div className='overflow-hidden rounded-xl border'>
1138
+ {segments.map((s) => (
1139
+ <div key={s.id} className='group flex items-center gap-2 border-b px-4 py-3 last:border-b-0 hover:bg-accent/50'>
1140
+ <button onClick={() => switchSegment(s.id)} className='flex min-w-0 flex-1 items-center gap-3 text-left'>
1141
+ <Folder className='size-4 text-muted-foreground' />
1142
+ <span className='font-medium'>{s.name}</span>
1143
+ <span className='text-xs text-muted-foreground'>{s.topics.length} topics · {ruleCount(s)} rules</span>
1144
+ </button>
1145
+ <span className='flex items-center gap-2 opacity-0 group-hover:opacity-100'>
1146
+ <button onClick={() => openEditSegment(s)} title='Rename'><Pencil className='size-4 text-muted-foreground hover:text-foreground' /></button>
1147
+ <button onClick={() => deleteSegment(s.id)} title='Delete'><Trash2 className='size-4 text-muted-foreground hover:text-red-600' /></button>
1148
+ </span>
1149
+ <ChevronRight className='size-4 text-muted-foreground' />
1150
+ </div>
1151
+ ))}
1152
+ </div>
1153
+ </div>
1154
+ )}
1155
+
1156
+ {rv === 'topics' && seg && (
1157
+ <div className='max-w-2xl'>
1158
+ <div className='mb-3 flex items-center justify-between'>
1159
+ <h2 className='text-lg font-medium'>{seg.name}</h2>
1160
+ <button onClick={() => openNewTopic(seg.id)} className={btn}><Plus className='size-4' /> New topic</button>
1161
+ </div>
1162
+ {seg.topics.length === 0 ? (
1163
+ <div className='rounded-xl bg-muted/50 p-8 text-center text-sm text-muted-foreground'>No topics yet in this segment.</div>
1164
+ ) : (
1165
+ <div className='overflow-hidden rounded-xl border'>
1166
+ {seg.topics.map((t) => (
1167
+ <div key={t.id} className='group flex items-center gap-2 border-b px-4 py-3 last:border-b-0 hover:bg-accent/50'>
1168
+ <button onClick={() => pickTopic(seg.id, t.id)} className='flex min-w-0 flex-1 items-center gap-3 text-left'>
1169
+ <Tag className='size-4 text-muted-foreground' />
1170
+ <span className='min-w-0 flex-1 truncate'>{t.name}</span>
1171
+ <span className='text-xs text-muted-foreground'>{t.rules.length} rules</span>
1172
+ </button>
1173
+ <span className='flex items-center gap-2 opacity-0 group-hover:opacity-100'>
1174
+ <button onClick={() => openEditTopic(seg.id, t)} title='Rename'><Pencil className='size-4 text-muted-foreground hover:text-foreground' /></button>
1175
+ <button onClick={() => deleteTopic(seg.id, t.id)} title='Delete'><Trash2 className='size-4 text-muted-foreground hover:text-red-600' /></button>
1176
+ </span>
1177
+ <ChevronRight className='size-4 text-muted-foreground' />
1178
+ </div>
1179
+ ))}
1180
+ </div>
1181
+ )}
1182
+ </div>
1183
+ )}
1184
+
1185
+ {rv === 'segForm' && (
1186
+ <div className='max-w-lg'>
1187
+ <h2 className='mb-1 text-lg font-medium'>{segForm.id ? 'Edit segment' : 'New segment'}</h2>
1188
+ <p className='mb-4 text-sm text-muted-foreground'>A segment is a top-level grouping that holds topics.</p>
1189
+ <label className='text-[11px] uppercase tracking-wide text-muted-foreground'>Name</label>
1190
+ <input autoFocus className={`${field} mt-1.5`} placeholder='e.g. Product Experience' value={segForm.name} onChange={(e) => setSegForm({ ...segForm, name: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') saveSegForm(); if (e.key === 'Escape') setRv(returnRv); }} />
1191
+ <div className='mt-4 flex justify-end gap-2'>
1192
+ <button onClick={() => setRv(returnRv)} className={btn}>Cancel</button>
1193
+ <button onClick={saveSegForm} disabled={!segForm.name.trim()} className={`${btn} border-primary text-primary disabled:pointer-events-none disabled:opacity-50`}>Save</button>
1194
+ </div>
1195
+ </div>
1196
+ )}
1197
+
1198
+ {rv === 'topicForm' && (
1199
+ <div className='max-w-lg'>
1200
+ <h2 className='mb-1 text-lg font-medium'>{topicForm.id ? 'Edit topic' : 'New topic'}</h2>
1201
+ <p className='mb-4 text-sm text-muted-foreground'>A topic lives under a segment and holds rules.</p>
1202
+ <label className='text-[11px] uppercase tracking-wide text-muted-foreground'>Segment</label>
1203
+ <select className={`${field} mt-1.5`} value={topicForm.segId} onChange={(e) => setTopicForm({ ...topicForm, segId: e.target.value })}>
1204
+ {segments.map((s) => <option key={s.id} value={s.id}>{s.id === 'none' ? '— No segment —' : s.name}</option>)}
1205
+ </select>
1206
+ <label className='mt-4 block text-[11px] uppercase tracking-wide text-muted-foreground'>Name</label>
1207
+ <input autoFocus className={`${field} mt-1.5`} placeholder='e.g. Performance' value={topicForm.name} onChange={(e) => setTopicForm({ ...topicForm, name: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') saveTopicForm(); if (e.key === 'Escape') setRv(returnRv); }} />
1208
+ <div className='mt-4 flex justify-end gap-2'>
1209
+ <button onClick={() => setRv(returnRv)} className={btn}>Cancel</button>
1210
+ <button onClick={saveTopicForm} disabled={!topicForm.name.trim() || !topicForm.segId} className={`${btn} border-primary text-primary disabled:pointer-events-none disabled:opacity-50`}>Save</button>
1211
+ </div>
1212
+ </div>
1213
+ )}
1214
+
1215
+ {rv === 'rules' &&
1216
+ (!topic ? (
1217
+ <div className='rounded-xl bg-muted/50 p-8 text-center text-sm text-muted-foreground'>Pick a topic to author rules.</div>
1218
+ ) : (
1219
+ <div className='flex items-start gap-4'>
1220
+ <div className='w-80 shrink-0'>
1221
+ <div className='mb-2 flex items-center justify-between'>
1222
+ <span className='text-sm font-medium'>{rules.length} rules</span>
1223
+ <button onClick={newRule} className={btnSm}><Plus className='size-3.5' /> New</button>
1224
+ </div>
1225
+ <div className='relative mb-2'>
1226
+ <Search className='absolute left-2.5 top-2 size-4 text-muted-foreground' />
1227
+ <input className={`${field} h-9 pl-8`} placeholder='Search rules' value={ruleSearch} onChange={(e) => setRuleSearch(e.target.value)} />
1228
+ </div>
1229
+ <div className='mb-2 flex gap-1.5'>
1230
+ <button onClick={() => setFUsesGroup((v) => !v)} className={`rounded-md px-2 py-0.5 text-[11px] transition-colors ${fUsesGroup ? 'bg-primary/10 text-primary' : 'bg-muted hover:bg-accent'}`}>uses #group</button>
1231
+ <button onClick={() => setFHasExclude((v) => !v)} className={`rounded-md px-2 py-0.5 text-[11px] transition-colors ${fHasExclude ? 'bg-primary/10 text-primary' : 'bg-muted hover:bg-accent'}`}>has exclude</button>
1232
+ </div>
1233
+ <div className='overflow-hidden rounded-md border'>
1234
+ {visibleRules.slice(0, ROW_CAP).map((r) => (
1235
+ <div key={r.id} className={`group flex items-start gap-1 border-b px-2.5 py-2 last:border-b-0 transition-colors ${r.id === selRule ? 'bg-primary/10' : 'hover:bg-accent/50'}`}>
1236
+ <button onClick={() => pickRule(r)} className='min-w-0 flex-1 text-left' title={r.match}>
1237
+ <div className='font-mono text-[12px] leading-relaxed break-words'><ExprText text={r.match} /></div>
1238
+ {r.exclude.length > 0 && (
1239
+ <div className='mt-1 flex flex-wrap items-center gap-1'>
1240
+ <span className='inline-flex items-center gap-0.5 text-[9px] font-medium uppercase tracking-wide text-rose-500/80'><Ban className='size-3' /> excl</span>
1241
+ {r.exclude.map((e, i) => (
1242
+ <span key={i} className={`break-all rounded px-1 py-px text-[10px] ${e.startsWith('#') ? 'bg-blue-50 font-mono text-blue-600 dark:bg-blue-950/50 dark:text-blue-300' : 'bg-rose-50 text-rose-600 dark:bg-rose-950/40 dark:text-rose-300'}`}>{e}</span>
1243
+ ))}
1244
+ </div>
1245
+ )}
1246
+ </button>
1247
+ <button onClick={() => deleteRule(r.id)} title='Delete rule' className='mt-0.5 opacity-0 group-hover:opacity-100'><Trash2 className='size-3.5 text-muted-foreground hover:text-red-600' /></button>
1248
+ </div>
1249
+ ))}
1250
+ {visibleRules.length === 0 && <div className='px-3 py-6 text-center text-sm text-muted-foreground'>No rules match.</div>}
1251
+ </div>
1252
+ {visibleRules.length > ROW_CAP && <div className='py-1.5 text-center text-[11px] text-muted-foreground'>{visibleRules.length - ROW_CAP} more · refine search</div>}
1253
+ </div>
1254
+
1255
+ <div className='min-w-0 flex-1'>
1256
+ {selRule ? (
1257
+ <div className='rounded-xl border-2 border-primary/60 p-4'>
1258
+ <label className='text-[11px] uppercase tracking-wide text-muted-foreground'>Match expression</label>
1259
+ {/* operator toolbar */}
1260
+ <div className='mt-1.5 flex flex-wrap gap-1.5'>
1261
+ <button className={opBtn} onClick={() => insert(' near ')} title='within N words — set distance below'>near</button>
1262
+ <button className={opBtn} onClick={() => insert(' and ')}>and</button>
1263
+ <button className={opBtn} onClick={() => insert(' or ')}>or</button>
1264
+ <button className={opBtn} onClick={() => insert('()', 1)}>( )</button>
1265
+ <button className={opBtn} onClick={() => insert('""', 1)}>&quot; &quot;</button>
1266
+ <button className={`${opBtn} text-amber-600 dark:text-amber-400`} onClick={() => insert('*')} title='wildcard: manage* matches manager, managing'>*</button>
1267
+ <button className={`${opBtn} text-amber-600 dark:text-amber-400`} onClick={() => insert('~')} title='fuzzy: word~ or ?word — tolerates typos'>~</button>
1268
+ <button className={`${opBtn} border-primary/40 text-blue-600 dark:text-blue-400`} onClick={() => insert('#')}># group</button>
1269
+ </div>
1270
+ <div className='mt-1.5'>
1271
+ <SlateMatchEditor ref={editorRef} value={match} groups={groups} onChange={setMatch} />
1272
+ </div>
1273
+
1274
+ {/* validation */}
1275
+ <div className='mt-1.5 text-xs'>
1276
+ {parsed.error ? (
1277
+ <span className='flex items-center gap-1 text-red-600 dark:text-red-400'><TriangleAlert className='size-3.5' /> {parsed.error}</span>
1278
+ ) : parsed.warn ? (
1279
+ <span className='flex items-center gap-1 text-amber-600 dark:text-amber-400'><TriangleAlert className='size-3.5' /> {parsed.warn}</span>
1280
+ ) : parsed.ast ? (
1281
+ <span className='flex items-center gap-1 text-emerald-600 dark:text-emerald-400'><CircleCheck className='size-3.5' /> Valid</span>
1282
+ ) : (
1283
+ <span className='flex items-center gap-1 text-muted-foreground'><Lightbulb className='size-3.5' /> type <span className='font-mono text-blue-600 dark:text-blue-400'>#</span> for a word group · <span className='italic'>near</span> = proximity</span>
1284
+ )}
1285
+ </div>
1286
+
1287
+ {/* distance steppers */}
1288
+ {nearOccurrences.length > 0 && (
1289
+ <div className='mt-2 flex flex-wrap items-center gap-2'>
1290
+ <span className='text-[11px] uppercase tracking-wide text-muted-foreground'>Proximity</span>
1291
+ {nearOccurrences.map((occ, i) => (
1292
+ <span key={i} className='inline-flex items-center gap-1 rounded-md border bg-muted/50 px-1.5 py-0.5 text-xs'>
1293
+ <span className='italic text-muted-foreground'>near {i + 1}</span> ≤
1294
+ <button className='px-1 text-muted-foreground hover:text-foreground' onClick={() => rewriteProx(i, { dist: Math.max(1, occ.dist - 1) })}>−</button>
1295
+ <span className='w-4 text-center font-medium'>{occ.dist}</span>
1296
+ <button className='px-1 text-muted-foreground hover:text-foreground' onClick={() => rewriteProx(i, { dist: Math.min(100, occ.dist + 1) })}>+</button>
1297
+ <span className='text-muted-foreground'>words</span>
1298
+ <button onClick={() => rewriteProx(i, { ordered: !occ.ordered })} title='in order: left term must come before right' className={`ml-1 rounded px-1 ${occ.ordered ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'}`}>{occ.ordered ? 'in order' : 'any order'}</button>
1299
+ </span>
1300
+ ))}
1301
+ </div>
1302
+ )}
1303
+
1304
+ {/* plain-English read-back */}
1305
+ {parsed.ast && (
1306
+ <div className='mt-2 text-xs text-muted-foreground'>
1307
+ <span className='text-muted-foreground/70'>Reads as:</span> {english(parsed.ast)}
1308
+ </div>
1309
+ )}
1310
+
1311
+ <div className='mt-4'>
1312
+ <label className='flex items-center gap-1.5 text-[11px] uppercase tracking-wide text-muted-foreground'>
1313
+ <Ban className='size-3.5 text-rose-500/80' /> Exclude
1314
+ <span className='normal-case tracking-normal text-muted-foreground/70'>— skip the comment if any of these appear</span>
1315
+ </label>
1316
+ <div className='relative mt-1.5'>
1317
+ <div className='flex flex-wrap items-center gap-1.5 rounded-md border bg-background p-1.5 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring'>
1318
+ {exclude.length === 0 && <span className='px-1 text-xs text-muted-foreground'>None — comment matches on the expression alone.</span>}
1319
+ {exclude.map((t, i) => {
1320
+ const isGroup = t.startsWith('#');
1321
+ const unknown = isGroup && !groupMap[t.slice(1)];
1322
+
1323
+ return (
1324
+ <span key={i} title={unknown ? 'Unknown word group' : isGroup ? `${groupMap[t.slice(1)]?.length ?? 0} terms` : 'literal word'} className={`group/chip inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs ${isGroup ? `font-mono ${unknown ? 'bg-amber-50 text-amber-700 ring-1 ring-amber-300 dark:bg-amber-950/50 dark:text-amber-300' : 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'}` : 'bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300'}`}>
1325
+ {unknown && <TriangleAlert className='size-3' />}
1326
+ {t}
1327
+ <X className='size-3 cursor-pointer opacity-60 group-hover/chip:opacity-100' onClick={() => setExclude(exclude.filter((_, idx) => idx !== i))} />
1328
+ </span>
1329
+ );
1330
+ })}
1331
+ <input
1332
+ className='h-6 min-w-32 flex-1 bg-transparent px-1 text-xs outline-none'
1333
+ placeholder='+ add word or #group'
1334
+ value={exInput}
1335
+ onChange={(e) => { setExInput(e.target.value); setExIndex(0); }}
1336
+ onFocus={() => setExFocus(true)}
1337
+ onBlur={() => setTimeout(() => setExFocus(false), 120)}
1338
+ onKeyDown={(e) => {
1339
+ if (exItems.length && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
1340
+ e.preventDefault();
1341
+ setExIndex((x) => Math.max(0, Math.min(exItems.length - 1, x + (e.key === 'ArrowDown' ? 1 : -1))));
1342
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
1343
+ if (exItems.length) { e.preventDefault(); addExclude(`#${exItems[exIndex].name}`); }
1344
+ else if (e.key === 'Enter') addExclude(exInput);
1345
+ } else if (e.key === 'Backspace' && !exInput && exclude.length) {
1346
+ setExclude(exclude.slice(0, -1));
1347
+ } else if (e.key === 'Escape') {
1348
+ setExInput('');
1349
+ }
1350
+ }}
1351
+ />
1352
+ </div>
1353
+ {exFocus && exItems.length > 0 && (
1354
+ <div className='absolute left-0 top-full z-30 mt-1 max-h-56 w-72 overflow-auto rounded-md border bg-background p-1 shadow-md'>
1355
+ {exItems.map((g, idx) => (
1356
+ <button key={g.id} onMouseDown={(e) => { e.preventDefault(); addExclude(`#${g.name}`); }} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${idx === exIndex ? 'bg-accent' : 'hover:bg-accent'}`}>
1357
+ <span className='font-mono text-blue-600 dark:text-blue-400'>#{g.name}</span>
1358
+ <span className='ml-auto text-[11px] text-muted-foreground'>{g.terms.length} terms</span>
1359
+ </button>
1360
+ ))}
1361
+ </div>
1362
+ )}
1363
+ </div>
1364
+ {unknownExcludes.length > 0 && (
1365
+ <p className='mt-1.5 flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-400'>
1366
+ <TriangleAlert className='size-3' /> Unknown word group{unknownExcludes.length > 1 ? 's' : ''}: {unknownExcludes.map((g) => `#${g}`).join(', ')}
1367
+ </p>
1368
+ )}
1369
+ </div>
1370
+
1371
+ <div className='mt-4 border-t pt-3.5'>
1372
+ <div className='flex items-center justify-between'>
1373
+ <span className='flex items-center gap-2 text-xs text-muted-foreground'>
1374
+ Test · click a word to exclude
1375
+ <button onClick={() => setStem((v) => !v)} title='Stemming: grow matches growing / grew. Analyzer-level setting, applies to every rule.' className={`rounded-md border px-1.5 py-0.5 text-[11px] ${stem ? 'border-primary/40 text-primary' : 'text-muted-foreground'}`}>stemming {stem ? 'on' : 'off'}</button>
1376
+ </span>
1377
+ <span className='flex gap-1.5'>
1378
+ <span className='rounded-md bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'>{matchedCount} matched</span>
1379
+ <span className='rounded-md bg-red-100 px-2 py-0.5 text-xs text-red-800 dark:bg-red-950 dark:text-red-300'>{excludedCount} excluded</span>
1380
+ </span>
1381
+ </div>
1382
+ <textarea
1383
+ value={testInput}
1384
+ onChange={(e) => setTestInput(e.target.value)}
1385
+ rows={3}
1386
+ placeholder='Paste comments to test — one per line'
1387
+ className='mt-2 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring'
1388
+ />
1389
+ <div className='mt-2.5'>
1390
+ {parsed.error ? (
1391
+ <div className='py-3 text-center text-sm text-muted-foreground'>Fix the expression to test it.</div>
1392
+ ) : samples.length === 0 ? (
1393
+ <div className='py-3 text-center text-sm text-muted-foreground'>Paste a comment above to test it.</div>
1394
+ ) : (
1395
+ results.map((r, i) => (
1396
+ <div key={i} className={`flex items-start gap-2.5 border-b py-1.5 ${r.status === 'miss' ? 'opacity-50' : ''}`}>
1397
+ <span className='mt-0.5 shrink-0'><StatusIcon status={r.status} /></span>
1398
+ <span className='text-sm leading-relaxed'>
1399
+ {r.parts.map((p, j) => (
1400
+ <span key={j}>
1401
+ <span onClick={() => addExclude(p.norm)} title={`click to exclude "${p.norm}"`} className={`cursor-pointer rounded px-0.5 ${p.kind === 'excluded' ? 'bg-red-100 text-red-800 line-through dark:bg-red-900/40 dark:text-red-300' : p.kind === 'match' ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300' : ''}`}>{p.text}</span>{' '}
1402
+ </span>
1403
+ ))}
1404
+ </span>
1405
+ </div>
1406
+ ))
1407
+ )}
1408
+ </div>
1409
+ </div>
1410
+
1411
+ <div className='mt-3'>
1412
+ <button onClick={() => setShowQuery((s) => !s)} className='flex items-center gap-1 text-xs text-primary'>
1413
+ {showQuery ? <ChevronDown className='size-3.5' /> : <ChevronRight className='size-3.5' />}
1414
+ <Code2 className='size-3.5' /> {showQuery ? 'Hide' : 'View'} compiled query
1415
+ </button>
1416
+ {showQuery && <pre className='mt-2 overflow-x-auto rounded-md bg-muted p-3 font-mono text-[11.5px] leading-relaxed text-muted-foreground'>{astToQuery(parsed.ast, exclude)}</pre>}
1417
+ </div>
1418
+
1419
+ <div className='mt-4 flex justify-end gap-2'>
1420
+ <button onClick={() => loadRule(rules.find((r) => r.id === selRule))} className={btn}>Cancel</button>
1421
+ <button onClick={saveRule} disabled={!!parsed.error} className={`${btn} border-primary text-primary disabled:pointer-events-none disabled:opacity-50`}>Save</button>
1422
+ </div>
1423
+ </div>
1424
+ ) : (
1425
+ <div className='rounded-xl bg-muted/50 p-8 text-center text-sm text-muted-foreground'>No rule selected. <button onClick={newRule} className='text-primary'>+ New rule</button></div>
1426
+ )}
1427
+ </div>
1428
+ </div>
1429
+ ))}
1430
+ </>
1431
+ )}
1432
+ </section>
1433
+ </div>
1434
+
1435
+ {confirm && (
1436
+ <div className='fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4' onClick={() => setConfirm(null)}>
1437
+ <div className='w-full max-w-sm rounded-xl border bg-background p-5 shadow-lg' onClick={(e) => e.stopPropagation()}>
1438
+ <p className='text-sm'>{confirm.message}</p>
1439
+ <div className='mt-5 flex justify-end gap-2'>
1440
+ <button onClick={() => setConfirm(null)} className={btn}>Cancel</button>
1441
+ <button
1442
+ onClick={() => { confirm.onYes(); setConfirm(null); }}
1443
+ className={`${btn} ${confirm.tone === 'primary' ? 'border-primary text-primary hover:bg-primary/10' : 'border-red-500 text-red-600 hover:bg-red-50 dark:hover:bg-red-950'}`}>
1444
+ {confirm.label ?? 'Delete'}
1445
+ </button>
1446
+ </div>
1447
+ </div>
1448
+ </div>
1449
+ )}
1450
+ </div>
1451
+ );
1452
+ }