File size: 41,007 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
// ---------------------------------------------------------------------------
// automation/AutomationFind.tsx β€” the discovery filter, as TOGGLES (owner item 6,
// contract C4).
//
// WHAT CHANGED AND WHY. It was a condition BUILDER: an empty row, a field
// dropdown listing 21 names, "Add a condition". That shape asks the user to
// remember what is searchable before they can look, and it hid the only fact
// that decides whether a search returns anything at all β€” which fields actually
// carry values. The corpus is 620 million profiles and every run is billed, so
// "I did not know I could filter on that" and "I filtered on a field that is
// empty on every row" are both expensive mistakes made in silence.
//
// So every searchable field is ON SCREEN, off by default, and turning one on is
// what creates its condition. The list leads with the fields MEASURED to carry
// values; the rest are grouped under what is known about them, and neither group
// is hidden β€” R3 already decided that "every filter" means the 21 we can stand
// behind, so all 21 are visible.
//
// β›” THE VOCABULARY IS THE SERVER'S, ALL OF IT. Field names, operators, which
// operators take no value, the record ceiling and (once C4 lands it) which
// fields are populated and which narrow β€” every one of those rides on
// `GET /automations`. This file holds no list of its own. The reason is the one
// `automationApi.ts:11-15` gives for cron presets: the thing that ACCEPTS a
// filter is Python, and a client copy of what it accepts is a copy that can
// offer a search the server refuses.
//
// β›” AND IT DOES NOT RE-IMPLEMENT THE GUARD. `guard` carries the server's
// numbers, so the rows can say which fields narrow β€” but the refusal is the
// server's to make and this surface prints it VERBATIM when it comes back
// (`DiscoverGuard`'s note). A client that predicts the refusal is a second copy
// of the rule, free to disagree with the first, and when they disagree the user
// gets a Save button that is disabled for a reason nobody can see.
// ---------------------------------------------------------------------------
import { useEffect, useState } from "react";
// ⭐ WAVE 27 item 29 β€” the overlay layer the grid already owns, consumed rather than
// re-implemented: it is a body portal (so it escapes `.auto-panel`'s 380px), and it brings
// the dismiss/focus/placement behaviour every other popover on this product already has.
import { AnchoredOverlay } from "../customer-grid/OverlaySurface";
import type { AnchorRect } from "../customer-grid/OverlaySurface";

import type {
  DiscoverEstimate,
  DiscoverFieldMeta,
  DiscoverOperator,
  DiscoverVocab,
  Predicate,
} from "./automationApi";
import { discoverCategories } from "./automationApi";

interface Props {
  /** The server-declared discovery vocabulary (absent until the list loads). */
  discover?: DiscoverVocab;
  recordsLimit: number;
  onRecordsLimit: (n: number) => void;
  joinOp: string;
  onJoinOp: (v: string) => void;
  preds: Predicate[];
  onPreds: (next: Predicate[]) => void;
  estimate: DiscoverEstimate | null;
  onEstimate: () => void;
}

/**
 * A FILTER FIELD'S ICON (owner item 5). Drawn, `currentColor`, never an emoji β€” and in the
 * existing `TriggerMark`/`ActionMark` language rather than a new one: same 16 viewBox, same 15px
 * box, same 1.4 stroke, so a row here and a card in the builder read as one product.
 *
 * ⚠ SHARED SHAPES WHERE THE FIELDS SHARE A MEANING, distinct everywhere else. `TriggerMark`'s
 * note settled the principle β€” a column of identical glyphs is decoration, the eye learns nothing
 * β€” but its converse matters just as much here: `bio_hashtags` and `post_hashtags` ARE both
 * hashtags, and drawing two different marks for them would invent a distinction the vendor does
 * not make. Twenty-one contrived glyphs would be twenty-one things to misread.
 *
 * β›” THE FALLBACK IS NOT A MEMBER OF THE SET, which is the `ActionMark` scar exactly: its default
 * used to BE the pencil, a real member, so every unmatched kind silently borrowed "edit" and a
 * fallback could not be told from a match. The vocabulary is the SERVER's β€” it can grow a field
 * tomorrow β€” so an unmatched name draws a neutral mark that is deliberately meaningless.
 */
function FilterMark({ name }: { name: string }) {
  const common = {
    width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
    strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
    "aria-hidden": true,
  };
  // Audience size β€” two figures, one behind the other.
  if (name === "followers")
    return (
      <svg {...common}>
        <circle cx="6.2" cy="5.6" r="2.4" />
        <path d="M2.2 13c.5-2 2.1-3.2 4-3.2s3.5 1.2 4 3.2" />
        <path d="M10.6 3.6a2.4 2.4 0 0 1 0 4M11.4 9.9c1.4.4 2.4 1.5 2.8 3.1" />
      </svg>
    );
  // Who this account follows β€” a figure with an outbound arrow.
  if (name === "following")
    return (
      <svg {...common}>
        <circle cx="6" cy="5.6" r="2.4" />
        <path d="M1.8 13c.5-2 2.1-3.2 4.2-3.2 .6 0 1.2.1 1.7.3" />
        <path d="M9.8 10.6h4.2M12.2 8.8l1.8 1.8-1.8 1.8" />
      </svg>
    );
  if (name === "posts_count")
    return (
      <svg {...common}>
        <rect x="2.6" y="2.6" width="4.6" height="4.6" rx="1" />
        <rect x="8.8" y="2.6" width="4.6" height="4.6" rx="1" />
        <rect x="2.6" y="8.8" width="4.6" height="4.6" rx="1" />
        <rect x="8.8" y="8.8" width="4.6" height="4.6" rx="1" />
      </svg>
    );
  // A story highlight β€” the ring Instagram draws around one.
  if (name === "highlights_count")
    return (
      <svg {...common}>
        <circle cx="8" cy="8" r="5.6" strokeDasharray="2.6 1.8" />
        <circle cx="8" cy="8" r="2.2" />
      </svg>
    );
  if (name === "avg_engagement")
    return (
      <svg {...common}>
        <path d="M8 13.2 3.4 8.8a2.9 2.9 0 0 1 4.6-3.4 2.9 2.9 0 0 1 4.6 3.4z" />
      </svg>
    );
  if (name === "biography")
    return (
      <svg {...common}>
        <rect x="2.4" y="2.8" width="11.2" height="10.4" rx="1.6" />
        <path d="M4.8 6h6.4M4.8 8.4h6.4M4.8 10.8h3.6" />
      </svg>
    );
  if (name === "category_name" || name === "business_category_name")
    return (
      <svg {...common}>
        <path d="M8.4 2.6H13v4.6l-6.2 6.2-4.6-4.6z" />
        <circle cx="10.8" cy="5.2" r="0.9" />
      </svg>
    );
  if (name === "is_business_account" || name === "is_professional_account")
    return (
      <svg {...common}>
        <rect x="2.2" y="5" width="11.6" height="8.2" rx="1.6" />
        <path d="M6 5V3.6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V5" />
      </svg>
    );
  if (name === "is_verified")
    return (
      <svg {...common}>
        <path d="M8 2.2l1.7 1.2 2-.2.6 2 1.7 1.1-.9 1.9.9 1.9-1.7 1.1-.6 2-2-.2L8 14.2l-1.7-1.2-2 .2-.6-2L2 10.1l.9-1.9L2 6.3l1.7-1.1.6-2 2 .2z" />
        <path d="m5.8 8.1 1.5 1.5 3-3.1" />
      </svg>
    );
  // The handle, and the display name it is written under.
  if (name === "account" || name === "profile_name")
    return (
      <svg {...common}>
        <circle cx="8" cy="8" r="2.4" />
        <path d="M10.4 5.6v3.2a1.9 1.9 0 0 0 3.2 1.3A5.8 5.8 0 1 0 11 13.1" />
      </svg>
    );
  if (name === "full_name")
    return (
      <svg {...common}>
        <circle cx="8" cy="5.6" r="2.6" />
        <path d="M3 13.4c.7-2.3 2.6-3.6 5-3.6s4.3 1.3 5 3.6" />
      </svg>
    );
  if (name === "external_url" || name === "profile_url")
    return (
      <svg {...common}>
        <path d="M6.6 9.4 4.9 11a2.6 2.6 0 1 1-1.5-4.4" />
        <path d="M9.4 6.6 11.1 5a2.6 2.6 0 1 1 1.5 4.4" />
        <path d="M6.2 8h3.6" />
      </svg>
    );
  if (name === "bio_hashtags" || name === "post_hashtags")
    return (
      <svg {...common}>
        <path d="M6.2 2.8 4.8 13.2M11.2 2.8 9.8 13.2M2.8 6h10.4M2.2 10h10.4" />
      </svg>
    );
  if (name === "related_accounts")
    return (
      <svg {...common}>
        <circle cx="8" cy="3.6" r="1.8" />
        <circle cx="3.6" cy="12" r="1.8" />
        <circle cx="12.4" cy="12" r="1.8" />
        <path d="M6.8 5.2 4.6 10.4M9.2 5.2l2.2 5.2M5.4 12h5.2" />
      </svg>
    );
  // The vendor's own record keys β€” the thing you hold to open exactly one row.
  if (name === "id" || name === "fbid")
    return (
      <svg {...common}>
        <circle cx="5.4" cy="10.6" r="2.6" />
        <path d="M7.2 8.8 13 3M10.6 5.4l1.6 1.6M9.2 6.8l1.6 1.6" />
      </svg>
    );
  return (
    <svg {...common}>
      <rect x="3.2" y="3.2" width="9.6" height="9.6" rx="2.2" />
      <circle cx="8" cy="8" r="1.4" />
    </svg>
  );
}

/** One field's line in the list, with everything known about it. */
interface FieldRow {
  name: string;
  /** What the row is CALLED. Falls back to the raw name only if the server sent none. */
  label: string;
  hint: string;
  kind: string;
  operators: DiscoverOperator[];
  options: { value: string; label: string }[];
  /** Every stored condition naming this field, with its index in the saved array. */
  at: number[];
  /** MEASURED to carry values. `null` = the server has not said (C4 not shipped yet). */
  populated: boolean | null;
  /** A condition here cuts the corpus down. `null` = not said. */
  narrowing: boolean | null;
}

/** A stored value β†’ what the text box shows. A list is the "any of" shape. */
function valueText(v: Predicate["value"]): string {
  if (Array.isArray(v)) return v.join(", ");
  return v === undefined || v === null ? "" : String(v);
}

/**
 * What the user typed β†’ what is stored. Comma-separated becomes a LIST for the operators that
 * accept one, and stays a plain string for the ones that do not.
 *
 * ⚠ A single value stays a SCALAR rather than a one-element list β€” the server sends scalars
 * flat and lists as a nested OR group, and a one-element group is a shape nothing has been
 * billed against.
 */
function parseValue(text: string, multi: boolean): Predicate["value"] {
  if (!multi) return text;
  const parts = text.split(",").map((s) => s.trim()).filter(Boolean);
  if (parts.length > 1) return parts;
  // Keep the RAW text while it is still being typed β€” trimming here would fight the cursor on
  // every keystroke, and the server trims anyway.
  return text;
}

/**
 * ⭐ WAVE 26 Β· DEBT D-69 β€” A THIRD VALUE COULD NOT BE TYPED, and the cause was one round trip
 * through this pair of functions.
 *
 * `parseValue` returns an ARRAY at 2+ values; `valueText` then renders that array as
 * `join(", ")` β€” which has NO TRAILING COMMA. So on a controlled input the separator was
 * normalised away between keystrokes: typing `,` onto `skincare, beauty` re-rendered as
 * `skincare, beauty`, and the next character landed as `skincare, beautym`. Two keywords
 * silently became one that matches nothing. ⚠ It hit the FIRST surface a new user meets (wave 24
 * made this panel the only door) and it hit precisely the 3+ keyword list the feature was built
 * for β€” the owner's *"if i want to include many keywords like floral, flower, beauty"*.
 *
 * β›” THE FIX IS THE ONE `parseValue`'s OWN COMMENT ALREADY DESCRIBES FOR THE 1-VALUE CASE:
 * keep the raw text while it is being typed. That comment was right and its scope was too narrow
 * β€” it protected the cursor at one value and handed the 2+ case to `join`. So the raw text is
 * held for whichever input is FOCUSED, and the stored value is still parsed on every keystroke
 * (so nothing depends on blur to save) and re-parsed on blur (so the display settles).
 *
 * ⚠ KEYED BY THE ROW'S REACT KEY, not by index alone: two conditions on the same field are
 * ordinary here ("Another condition on Bio"), and an index-only key would leak one row's draft
 * into its sibling on a removal.
 */
interface Draft {
  key: string;
  text: string;
}

/**
 * The comparison a freshly toggled field starts with β€” ASKED FOR, never chosen here.
 *
 * β›” THE THIRD COSTUME OF ONE SCAR, and the reason this function no longer has an opinion.
 * Wave 21 made a new condition NULLARY (`is_not_null`) so it would be saveable without a
 * value, and called that "narrowing-in-the-right-direction". Wave 22 then wrote the narrowing
 * law server-side and `is_not_null` is not in it. From that moment, toggling ANY field on and
 * pressing Save returned "add at least one CONTENT condition…" β€” the error told the user to do
 * the exact thing they had just done. Wave 24 deleted the wizard and made this panel the only
 * door, so it stopped being a corner case and became the first thing a new user hits.
 *
 * The fix is not a better guess. It is that the module owning the guard also names the default
 * (`automation_engine.default_operator`, asserted against `predicate_narrows` in
 * `verify_automation.py`), and this file looks it up per FIELD. An absent one falls back to the
 * server's first operator β€” never to a nullary, which is the thing that could not narrow.
 */
function firstOperator(v: DiscoverVocab | undefined, name: string): string {
  const meta = (v?.filterMeta || []).find((m) => m.name === name);
  return meta?.defaultOperator || (v?.operators || [])[0] || "";
}

/**
 * The rows, ordered: measured-populated first, then the rest.
 *
 * TWO SOURCES, AND THE FALLBACK IS NARROWER THAN THE REAL THING. With C4's
 * `filterMeta` the answer is per field and complete. Without it, `lead` names
 * the three fields seen carrying values and says NOTHING about the other
 * eighteen β€” so those come back `null`, not `false`, and the UI prints no claim
 * about them rather than an invented one.
 */
function buildRows(preds: Predicate[], v?: DiscoverVocab): FieldRow[] {
  const meta = new Map<string, DiscoverFieldMeta>(
    (v?.filterMeta || []).map((m) => [m.name, m])
  );
  const lead = new Set(v?.lead || []);
  const byName = new Map<string, number[]>();
  preds.forEach((p, i) => {
    const list = byName.get(p.name);
    if (list) list.push(i);
    else byName.set(p.name, [i]);
  });

  const rows = (v?.fields || []).map((name) => {
    const m = meta.get(name);
    return {
      name,
      label: m?.label || name,
      hint: m?.hint || "",
      kind: m?.kind || "text",
      // β›” NO FALLBACK TO THE GLOBAL OPERATOR LIST. An older server that sends no per-field list
      // gets a row with no comparisons rather than all fourteen on every field β€” visibly
      // unfinished beats quietly offering `at least` on a yes/no column.
      operators: m?.operators || [],
      options: m?.options || [],
      at: byName.get(name) || [],
      populated: m ? !!m.populated : lead.has(name) ? true : null,
      narrowing: m ? !!m.narrowing : null,
    };
  });
  // Stable partition, never a sort: the server's order inside each half is the
  // order the engine lists them in, and re-alphabetising it would be this file
  // having an opinion about a server list.
  return [...rows.filter((r) => r.populated === true), ...rows.filter((r) => r.populated !== true)];
}

export default function AutomationFind({
  discover,
  recordsLimit,
  onRecordsLimit,
  joinOp,
  onJoinOp,
  preds,
  onPreds,
  estimate,
  onEstimate,
}: Props) {
  const rows = buildRows(preds, discover);
  const known = new Set(discover?.fields || []);
  // The GLOBAL operator list is gone from this component: comparisons are per field now
  // (`row.operators`). `nullary` survives for STRAYS only β€” a stored condition on a field the
  // vendor dropped has no per-field row to read from, and it still has to render to be removable.
  const nullary = new Set(discover?.nullaryOperators || []);
  const withValues = rows.filter((r) => r.populated === true);
  const rest = rows.filter((r) => r.populated !== true);
  const knownMeta = !!(discover?.filterMeta || []).length;
  // ⚠ THE STORED FILTER CAN NAME A FIELD THE LIST NO LONGER HAS β€” a vocabulary
  // that moved, an automation copied from elsewhere. It must stay VISIBLE and
  // REMOVABLE: a condition the user cannot see is one they cannot delete, and
  // every Save keeps sending it. Same defect as a `<select>` whose value matches
  // no option, one level up.
  const strays = preds
    .map((p, i) => ({ p, i }))
    .filter(({ p }) => !known.has(p.name));

  const maxRecords = discover?.guard?.maxRecords || discover?.maxRecords || 500;

  /** D-69 β€” the raw text of the input a person is typing in right now (see `Draft`). */
  const [draft, setDraft] = useState<Draft | null>(null);

  /**
   * ⭐ WAVE 27 Β· OWNER ITEM 29 β€” which condition's value is open in the BIG editor, if any.
   *
   * β›” THE TEXT IS HELD HERE, NOT WRITTEN THROUGH ON EVERY KEYSTROKE, and that is the one place
   * this differs from the rail input beside it. The rail input writes per keystroke on purpose
   * (a Save pressed straight from the field must not store the previous value β€” D-69's note).
   * A modal has its own Save, so per-keystroke writes would buy nothing and would make Cancel
   * a lie: the value would already be in the config by the time it was pressed.
   * ⚠ The ANCHOR is captured at click time and stored, because the button it came from is
   * inside a panel that scrolls β€” re-reading it later would place the overlay against a rect
   * that has moved.
   */
  const [big, setBig] = useState<{
    key: string;
    index: number;
    label: string;
    multi: boolean;
    text: string;
    anchor: AnchorRect;
  } | null>(null);

  /**
   * ⭐ ITEM 16 / D-71 β€” THE OBSERVED CATEGORY VALUES, fetched WHEN THIS PANEL OPENS.
   *
   * β›” ON MOUNT, ONCE, AND NEVER ON THE POLLED PAYLOAD. This component is rendered only while the
   * Find panel is open, so mounting IS the panel opening β€” and the server moved these off
   * `GET /automations` after measuring that deriving them reads two user tables plus the platform
   * master, i.e. a network round-trip per open tab every 2.5 s. Putting the request here is the
   * client half of that same decision, not an optimisation.
   *
   * ⚠ AN EMPTY LIST IS AN ANSWER AND IS RENDERED AS ONE. It means this deployment has not seen a
   * category value yet β€” which is TRUE for a fresh tenant β€” and the field keeps its free-text
   * input either way. It must never read as "loading forever", and it must never be filled in
   * with a guess: D-59 is explicit that an invented taxonomy is worse than no dropdown.
   */
  const [cats, setCats] = useState<{ value: string; count: number }[]>([]);
  useEffect(() => {
    const ac = new AbortController();
    discoverCategories(ac.signal)
      .then((r) => setCats(Array.isArray(r?.options) ? r.options : []))
      // Fail-quiet, on purpose: the picker is an ASSIST over a control that works without it.
      // An error banner here would report a broken panel when the only thing missing is a
      // convenience β€” and the free-text input beside it is unaffected.
      .catch(() => setCats([]));
    return () => ac.abort();
  }, []);

  const setAt = (i: number, patch: Partial<Predicate>) =>
    onPreds(preds.map((p, j) => (j === i ? { ...p, ...patch } : p)));

  const removeAt = (i: number) => onPreds(preds.filter((_p, j) => j !== i));

  const toggleField = (row: FieldRow) => {
    if (row.at.length) {
      onPreds(preds.filter((p) => p.name !== row.name));
      return;
    }
    onPreds([...preds, { name: row.name, operator: firstOperator(discover, row.name) }]);
  };

  /** One condition line β€” the comparison, its value, and a way out. */
  const condition = (i: number, label: string, row?: FieldRow) => {
    const p = preds[i];
    if (!p) return null;
    // The field's OWN comparisons. `row` is absent only for a stray (a stored condition on a
    // field the vendor no longer offers), which keeps the raw token so it stays removable.
    const ops = row?.operators || [];
    const cur = ops.find((o) => o.value === p.operator);
    const isNullary = cur ? cur.nullary : nullary.has(p.operator);
    const isMulti = !!cur?.multi;
    const options = row?.options || [];
    const rowKey = `${p.name}-${i}`;
    // D-69: the focused input shows what was TYPED; every other one shows the stored value.
    const shown = draft && draft.key === rowKey ? draft.text : valueText(p.value);
    /*
     * ⭐ ITEM 16 / D-71 β€” WHICH FIELDS GET THE OBSERVED-VALUES PICKER, DERIVED OFF THE WIRE.
     *
     * β›” NOT A CLIENT-SIDE LIST OF FIELD NAMES. Naming the vendor's two category columns here
     * would be the D-55 class verbatim β€” the wave-9 silent-drop shape, a client copy of a server
     * vocabulary that is free to drift the day the engine adds a third one. (Their literal keys
     * are deliberately not written anywhere in this file: the gate asserts the tokens are ABSENT,
     * and an absence check cannot tell a hard-coded list from the comment forbidding one.)
     * The server already publishes the answer twice over: `kind: "choice"` is its own declaration
     * (`BD_FIELD_KINDS`), and `filter_meta` deliberately ships `options: []` for those fields
     * because their vocabulary is OBSERVED rather than declared and arrives on its own route.
     * So the rule is exactly that: a choice-kind field with no declared options is one whose
     * values we can only have learned by seeing them. A new choice field inherits this for free.
     */
    const wantsObserved = row?.kind === "choice" && !options.length;
    /** D-71 β€” APPEND, never replace: the picker has to compose with the comma list. */
    const appendValue = (v: string) => {
      const has = shown
        .split(",")
        .map((s) => s.trim().toLowerCase())
        .filter(Boolean);
      // Picking the same value twice is a no-op rather than a duplicate: the server expands a
      // multi-value condition into an OR, and `beauty OR beauty` costs a slot to say nothing.
      if (has.includes(v.trim().toLowerCase())) return;
      const next = shown.trim() ? `${shown.replace(/[\s,]+$/, "")}, ${v}` : v;
      setDraft(null);
      setAt(i, { value: parseValue(next, isMulti) });
    };
    return (
      <div className="autob-cond" key={rowKey}>
        <select
          className="auto-input is-small"
          aria-label={`Comparison for ${label}`}
          value={p.operator}
          onChange={(e) => setAt(i, { operator: e.target.value })}
        >
          {/* The stored value is ALWAYS an option. A select whose value matches no
              option renders the FIRST one, and the next Save writes a comparison
              nobody chose β€” this codebase has paid for that twice. */}
          {p.operator && !ops.some((o) => o.value === p.operator) ? (
            <option value={p.operator}>{p.operator}</option>
          ) : null}
          {ops.map((o) => (
            <option key={o.value} value={o.value}>
              {o.label}
            </option>
          ))}
        </select>
        {isNullary ? null : options.length ? (
          // A fixed vocabulary gets a dropdown. Owner item 2: a yes/no column was a box you
          // typed `true` into, beside a comparison list that offered "at least".
          <select
            className="auto-input is-small"
            aria-label={`Value for ${label}`}
            value={valueText(p.value)}
            onChange={(e) => setAt(i, { value: e.target.value })}
          >
            <option value="">Choose…</option>
            {options.map((o) => (
              <option key={o.value} value={o.value}>
                {o.label}
              </option>
            ))}
          </select>
        ) : (
          <input
            className="auto-input is-small"
            type={row?.kind === "number" ? "number" : "text"}
            aria-label={`Value for ${label}`}
            // ⭐ OWNER ITEM 3. Several comma-separated values become an "any of" condition, which
            // the server sends as its own OR group β€” so it does NOT drag Followers into a union
            // the way the Match dropdown would.
            placeholder={isMulti ? "floral, flower, beauty" : ""}
            // D-69: `shown` is the RAW text while this input has focus, so a trailing comma
            // survives long enough to type the next value after it.
            value={shown}
            onFocus={() => setDraft({ key: rowKey, text: valueText(p.value) })}
            onChange={(e) => {
              setDraft({ key: rowKey, text: e.target.value });
              // The STORE still updates on every keystroke. Parsing only on blur would mean a
              // Save pressed straight from the field wrote the previous value β€” trading a typing
              // bug for a data-loss one.
              setAt(i, { value: parseValue(e.target.value, isMulti) });
            }}
            onBlur={(e) => {
              setDraft(null);
              setAt(i, { value: parseValue(e.target.value, isMulti) });
            }}
          />
        )}
        {/* ⭐ WAVE 27 Β· OWNER ITEM 29 β€” A WIDE EDITOR FOR THE VALUES THAT DO NOT FIT.
            β›” THE COMPLAINT IS GEOMETRY, and the geometry is a CHAIN nothing here can widen:
            `.auto-panel` is 380px, `.autob-cond .auto-input` divides what is left across three
            controls, and `.is-small` trims it again β€” so a bio phrase list ("floral, flower,
            wedding florist, event styling") is typed six characters at a time through a box
            that scrolls sideways. The panel cannot grow; the EDITOR can leave it, and an
            overlay is drawn in a body portal, so it is not bound by that chain at all.
            ⚠ ALWAYS OFFERED, never "appears once the text is long": a control that materialises
            at some threshold is one people do not know exists at the moment they need it.
            ⚠ NOT on a `<select>` value (a fixed vocabulary has nothing to expand) and not on a
            nullary comparison (which has no value at all). */}
        {!isNullary && !options.length ? (
          <button
            type="button"
            className="auto-input is-small autoc-expand"
            aria-label={`Open a bigger editor for ${label}`}
            title="Edit in a bigger box"
            onClick={(e) => {
              // ⚠ READ SYNCHRONOUSLY from the event. `currentTarget` is null by the time React
              // re-invokes a state updater, and a null rect here paints the overlay at 0,0 β€”
              // measured, in this repo, on a different control ([[react-event-currenttarget-updater]]).
              const r = e.currentTarget.getBoundingClientRect();
              setBig({
                key: rowKey,
                index: i,
                label,
                multi: isMulti,
                text: valueText(p.value),
                anchor: { left: r.left, top: r.top, right: r.right, bottom: r.bottom,
                          width: r.width, height: r.height },
              });
            }}
          >
            {/* Drawn, never a glyph: the design constitution's "no emojis in the UI" covers
                the arrow characters too, and every other mark in this product is a path in the
                same 16x16 stroke vocabulary. Two corners pulling apart = "make this bigger". */}
            <svg viewBox="0 0 16 16" aria-hidden className="autoc-expand-icon">
              <path d="M9.5 2.5h4v4M13.5 2.5 9.6 6.4" />
              <path d="M6.5 13.5h-4v-4M2.5 13.5l3.9-3.9" />
            </svg>
          </button>
        ) : null}
        {/* ⭐ ITEM 16 / D-71 β€” THE APPEND-PICKER, BESIDE the input and never instead of it.
            Free text stays the primary control: these are the values this deployment has SEEN,
            not the values that exist, so the picker is a shortcut over a superset it cannot
            enumerate. Appending in code also sidesteps D-69 entirely β€” a picked value never has
            to survive a round trip through the text box. */}
        {wantsObserved && !isNullary ? (
          <select
            className="auto-input is-small autoc-catpick"
            aria-label={`Add a value seen before, to ${label}`}
            title={
              cats.length
                ? "Values this workspace has actually seen, most-seen first. Adds to the list."
                : "No category values have been seen in your data yet β€” type one."
            }
            value=""
            disabled={!cats.length}
            onChange={(e) => {
              if (e.target.value) appendValue(e.target.value);
            }}
          >
            <option value="">{cats.length ? "Seen before…" : "None seen yet"}</option>
            {cats.map((c) => (
              // The COUNT is the honest half (D-59): a value seen once and one seen forty times
              // are different bets, and an alphabetical list of bare strings hides that.
              <option key={c.value} value={c.value}>
                {c.value} ({c.count.toLocaleString()})
              </option>
            ))}
          </select>
        ) : null}
        {isMulti && Array.isArray(p.value) && p.value.length > 1 ? (
          /*
           * ⭐ ITEM 16 / D-71 β€” HOW MANY VALUES THIS CONDITION CARRIES, and against what ceiling.
           *
           * The COUNT is ours and is always shown. The CEILING is the server's and is shown only
           * when the server sends one (`guard.maxValues` β€” an open `ASK ->A:`; `guard` ships
           * `{minNarrowing, maxRecords}` today).
           * β›” NO CLIENT-SIDE `4`. D-71's exit says "how many of D-68's 4 value slots" β€” but A
           * shipped `depth_refusal()`, which computes the depth of the shape actually emitted
           * rather than budgeting fixed slots, so the number 4 describes a model that no longer
           * exists. Printing it would be a client copy of a server rule that has already changed
           * once, which is the specific failure this file's header refuses to commit
           * ([[measure-the-real-call]]: a correct-looking answer about the wrong subject).
           */
          <span className="autob-cond-any" title="Any one of these is a match">
            any of {p.value.length}
            {discover?.guard?.maxValues ? ` of ${discover.guard.maxValues}` : ""}
          </span>
        ) : null}
        <button
          type="button"
          className="autob-cond-x"
          aria-label={`Remove this condition on ${label}`}
          title="Remove this condition"
          onClick={() => removeAt(i)}
        >
          <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
            <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
          </svg>
        </button>
      </div>
    );
  };

  const fieldRow = (row: FieldRow) => {
    const on = row.at.length > 0;
    return (
      <div className={"autob-tgl" + (on ? " is-on" : "")} key={row.name}>
        <div className="autob-tgl-head">
          <button
            type="button"
            className={"auto-step-switch autob-tgl-switch" + (on ? " is-on" : "")}
            aria-pressed={on}
            aria-label={`${on ? "Stop searching" : "Search"} on ${row.label}`}
            title={`${on ? "Stop searching" : "Search"} on ${row.label}`}
            onClick={() => toggleField(row)}
          >
            <span className="auto-step-switch-knob" />
          </button>
          <span className="autob-tgl-mark">
            <FilterMark name={row.name} />
          </span>
          {/* ⭐ THE HUMAN LABEL (owner item 1: "wtf is id/fbid").
              This used to render the vendor's own column name verbatim, and the comment
              defending it was RIGHT about the reason β€” a refusal names the field, and a label
              that reads differently makes the error unmatchable. So the label did not move
              alone: `field_label()` is what the SERVER's refusals use too, which is the only
              version of this change that does not trade one confusion for another. */}
          <span className="autob-tgl-name" title={row.hint || undefined}>
            {row.label}
          </span>
          {/*
            ⚠ ONE CHIP, AND IT MARKS THE MINORITY. The first version chipped every
            non-narrowing field "broad" AND every unpopulated one "no values seen" β€”
            which put two chips on 18 of the 21 rows, and one of them repeated the group
            heading the row was already sitting under. A mark that is on almost every row
            stops being a mark. Read off the screenshot; no assertion could have said it.

            So: the group label carries POPULATED (it is what the groups ARE), and the
            chip carries NARROWING, inverted to mark the five that DO narrow. That is the
            actionable half β€” the server refuses a filter with no narrowing condition, so
            "these are the ones that satisfy it" is the sentence a user needs, and
            "this one does not" is not.
          */}
          {row.narrowing === true ? (
            <span
              className="autob-tgl-flag is-narrow"
              title="A search needs at least one condition like this."
            >
              narrows
            </span>
          ) : null}
        </div>
        {on ? (
          <div className="autob-tgl-body">
            {row.at.map((i) => condition(i, row.label, row))}
            {row.hint ? <p className="autob-tgl-hint">{row.hint}</p> : null}
            <button
              type="button"
              className="autob-cond-add"
              onClick={() =>
                onPreds([...preds, { name: row.name, operator: firstOperator(discover, row.name) }])
              }
            >
              Another condition on {row.label}
            </button>
          </div>
        ) : null}
      </div>
    );
  };

  return (
    <>
      <div className="auto-field">
        <label htmlFor="auto-records">How many profiles to fetch</label>
        <input
          id="auto-records"
          className="auto-input"
          type="number"
          min={1}
          max={maxRecords}
          value={recordsLimit}
          onChange={(e) => onRecordsLimit(Number(e.target.value) || 1)}
        />
      </div>
      {/* Owner item 5: "Stop at the 'A search must be at most 500 profiles a run'". The two
          sentences that followed explained the vendor's billing model to somebody who wants a
          list of florists. */}
      <p className="auto-hint">At most {maxRecords.toLocaleString()} profiles a run.</p>

      <h3>Conditions</h3>
      <div className="auto-field autob-match">
        <label htmlFor="auto-joinop">Match</label>
        <select
          id="auto-joinop"
          className="auto-input"
          value={joinOp}
          onChange={(e) => onJoinOp(e.target.value)}
        >
          <option value="and">All of them</option>
          <option value="or">Any of them</option>
        </select>
      </div>

      {!rows.length ? (
        <p className="auto-note">
          The searchable fields have not loaded yet.
        </p>
      ) : null}

      {withValues.length ? (
        <div className="autob-tglgroup">
          <p
            className="autob-tglgroup-label"
            title="Most accounts have this filled in."
          >
            Usually filled in
          </p>
          {withValues.map(fieldRow)}
        </div>
      ) : null}

      {rest.length ? (
        <div className="autob-tglgroup">
          <p
            className="autob-tglgroup-label"
            title={
              knownMeta
                ? "Most accounts leave these blank, so a condition here can return nothing."
                : "We have not measured how often these are filled in."
            }
          >
            {knownMeta ? "Often blank" : "Not measured"}
          </p>
          {rest.map(fieldRow)}
        </div>
      ) : null}

      {strays.length ? (
        <div className="autob-tglgroup">
          <p className="autob-tglgroup-label">Not in the searchable list</p>
          {strays.map(({ p, i }) => (
            <div className="autob-tgl is-on is-stray" key={`stray-${i}`}>
              <div className="autob-tgl-head">
                {/* A stray gets a mark too β€” "every filter row" includes the ones the vendor no
                    longer offers, and a row missing the icon its neighbours have would read as a
                    different KIND of thing rather than as a field that fell out of the list. It
                    draws the neutral fallback by construction: a stray is, by definition, not in
                    the vocabulary this component has arms for. */}
                <span className="autob-tgl-mark">
                  <FilterMark name={p.name} />
                </span>
                <span className="autob-tgl-name">{p.name}</span>
                <span className="autob-tgl-flag is-stray" title="The server will refuse this condition by name.">
                  not searchable
                </span>
              </div>
              <div className="autob-tgl-body">{condition(i, p.name)}</div>
            </div>
          ))}
        </div>
      ) : null}

      {/* The consequence kept, the mechanism dropped: a condition on an often-blank field
          returning nothing is a thing that will happen to somebody and confuse them. Why the
          search is slow when broad is our problem, not theirs. */}
      <p className="auto-hint">
        Conditions on often-blank fields can come back with nothing.
      </p>

      <h3>What it costs</h3>
      <div className="auto-head-actions">
        <button type="button" className="auto-btn" onClick={onEstimate}>
          Estimate this search
        </button>
      </div>
      {estimate ? (
        <p className="auto-hint">
          About <strong>${estimate.usd}</strong> for {estimate.records} profiles, at $
          {estimate.unitUsd} each.{" "}
          {/*
            THE CAVEAT IS NOT OPTIONAL. The vendor never quotes a price before a run and this
            deployment cannot read its own balance, so presenting this as a billed figure
            would be inventing a measurement.
          */}
          <strong>This is an estimate</strong> β€” {estimate.note}.
        </p>
      ) : null}

      {/* ⭐ WAVE 27 Β· OWNER ITEM 29 β€” THE BIG EDITOR, rendered ONCE for the whole panel.
          β›” Not one overlay per condition row: `AnchoredOverlay` mounts a body portal and
          installs document-level dismiss/focus handlers, so N of them would be N listener
          stacks fighting over one Escape key. One overlay, told which row it is editing.
          ⚠ `role="dialog"`, not "menu": it holds a text field, and the menu layer's arrow-key
          handling would take the cursor keys away from the text being typed. */}
      {big ? (
        <AnchoredOverlay
          anchor={big.anchor}
          className="autoc-bigedit"
          placement="right-start"
          role="dialog"
          ariaLabel={`Value for ${big.label}`}
          onDismiss={() => setBig(null)}
          dataKind="find-value-editor"
        >
          <label htmlFor="autoc-bigedit-text">{big.label}</label>
          <textarea
            id="autoc-bigedit-text"
            className="autoc-bigedit-text"
            data-overlay-autofocus
            autoFocus
            rows={7}
            spellCheck={false}
            value={big.text}
            placeholder={big.multi ? "floral, flower, wedding florist, event styling" : ""}
            onChange={(e) => setBig((cur) => (cur ? { ...cur, text: e.target.value } : cur))}
            onKeyDown={(e) => {
              // Escape abandons; Ctrl/Cmd+Enter commits. A bare Enter types a NEWLINE, because
              // this is a textarea and the whole reason it exists is that the value is long.
              if (e.key === "Escape") setBig(null);
              if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                setAt(big.index, { value: parseValue(big.text, big.multi) });
                setBig(null);
              }
            }}
          />
          {/* The same sentence the rail input's placeholder makes, said where there is room
              for it: a comma list is several values, and the server turns them into an OR. */}
          {big.multi ? (
            <p className="auto-hint">
              Separate values with commas β€” the search matches any of them.
            </p>
          ) : null}
          <div className="auto-head-actions">
            <button
              type="button"
              className="auto-btn auto-btn--primary"
              onClick={() => {
                setAt(big.index, { value: parseValue(big.text, big.multi) });
                setBig(null);
              }}
            >
              Done
            </button>
            <button type="button" className="auto-btn" onClick={() => setBig(null)}>
              Cancel
            </button>
          </div>
        </AnchoredOverlay>
      ) : null}
    </>
  );
}