File size: 44,506 Bytes
d349fee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20bf676
 
d349fee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d349fee
 
 
 
 
20bf676
d349fee
 
 
 
30d8096
 
 
 
 
 
 
 
 
d349fee
20bf676
d349fee
30d8096
d349fee
 
 
 
 
 
 
 
 
 
20bf676
d349fee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
// ---------------------------------------------------------------------------
// customer-grid / JsonViewer.tsx β€” ⭐ wave-23 C7 (owner item 5 / ruling R5).
//
// THE BIG VIEWER a json cell opens on click. One component, reached from two
// places (the grid cell's click path and the record drawer's value slot), for
// the reason `assetUrl` is one resolver: two readers of the same document that
// disagree about how it folds is a bug nobody can see in either one alone.
//
// The document is a STRING at rest (the scalar Row contract β€” see the `json`
// note in types.ts), so everything here is text in and text out. Nothing in
// this file ever writes a parsed OBJECT back.
//
// THREE decisions worth stating, because each has a plausible alternative:
//
//  1. THE RAW TAB IS THE EDITOR, and there is no field-by-field one. A form
//     over a document whose shape nobody declared would have to invent that
//     shape from the current value β€” so adding a key would mean editing the
//     form's idea of the schema, and the first machine write with a different
//     shape would silently drop what it did not recognise.
//  2. PARSE ON SAVE, REFUSE WITH A SENTENCE. Never repair, never re-indent on
//     the way in: `{a:1}` is not JSON and quietly "fixing" it to `{"a":1}`
//     teaches a person their typo was fine, until the day a value cannot be
//     guessed. The host applies the same rule at the write wall (C7), so a
//     refusal here says the same thing the server would.
//  3. THE TREE COLLAPSES BY DEPTH, not by hand-kept state per node beyond what
//     the reader opens. A 200-key payload that arrives fully expanded is a
//     scroll bar, not a document.
//
// ⭐ WAVE-27 item 13 (owner ruling R13) AMENDS decision 3: THE TREE IS EDITABLE
// NOW, two-way synced with the raw tab, and the raw text WINS on conflict.
//
// "Wins" is structural rather than a rule anybody has to enforce: `draft` (the
// raw text) is the single source of truth, `parsed` is derived from it, and the
// tree is a projection of `parsed`. A tree edit writes a new VALUE at a path,
// re-serialises the whole document and sets `draft` β€” so there is never a second
// copy of the document to reconcile, and a draft that stops parsing simply has
// no tree to show (the raw tab is where it gets read, exactly as before).
//
// β›” WHAT THE TREE EDITS, AND WHAT IT DELIBERATELY DOES NOT. Leaf VALUES, in
// place, keeping each leaf's own type. NOT keys, NOT structure, NOT a value's
// type β€” those stay on the raw tab, and that is decision 1 above still standing:
// a form that can add a key has to have an opinion about the document's shape,
// and nobody declared one. Editing `"12"` into `12` is a shape change, so it is
// a raw edit; editing `"Anna"` into `"Anne"` is a value change, so it is here.
// ---------------------------------------------------------------------------

import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { BodyPortal, useOverlayLayer } from "./OverlaySurface";
import { dayText, jsonParse, jsonPretty, MAX_JSON_BYTES, postsWindowOf } from "./display";
import type { PostSummary, PostsWindow } from "./display";

/** Bytes, not characters β€” the host's cap is on the stored UTF-8, and a

 *  document of 20,000 emoji is 80 KB. Counting `length` would let a value

 *  through here that the server then refuses, which is the one refusal a user

 *  cannot act on (nothing on screen said it was too big). */
export function jsonByteLength(text: string): number {
  return typeof TextEncoder === "undefined"
    ? text.length
    : new TextEncoder().encode(text).length;
}

/** The one-line summary in the modal's header: what this document IS. */
export function jsonSummary(text: string): string {
  const parsed = jsonParse(text);
  const bytes = jsonByteLength(text);
  const size = bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} KB`;
  if (String(text ?? "").trim() === "") return "Empty";
  if (!parsed.ok) return `Not valid JSON Β· ${size}`;
  const v = parsed.value;
  if (Array.isArray(v)) return `${v.length} item${v.length === 1 ? "" : "s"} Β· ${size}`;
  if (v !== null && typeof v === "object") {
    const n = Object.keys(v as object).length;
    return `${n} key${n === 1 ? "" : "s"} Β· ${size}`;
  }
  return `${v === null ? "null" : typeof v} Β· ${size}`;
}

// --- the tree ---------------------------------------------------------------

/** How deep a node is open when the viewer mounts. Two levels shows the shape

 *  of every payload this product actually receives (an object of objects) and

 *  stops before the leaf spam of a scraped array. */
const OPEN_TO_DEPTH = 2;

function isBranch(v: unknown): v is Record<string, unknown> | unknown[] {
  return v !== null && typeof v === "object";
}

/** The value's own text, in its own ink class. Strings keep their quotes: a

 *  bare `12` and a `"12"` are different documents and the tree is where that

 *  difference has to be visible. */
function Leaf({ v }: { v: unknown }): ReactNode {
  if (v === null) return <span className="cg-json-null">null</span>;
  if (typeof v === "string") return <span className="cg-json-str">&quot;{v}&quot;</span>;
  if (typeof v === "number") return <span className="cg-json-num">{String(v)}</span>;
  if (typeof v === "boolean") return <span className="cg-json-bool">{String(v)}</span>;
  return <span className="cg-json-str">{String(v)}</span>;
}

/**

 * ⭐ R13 β€” the path a tree edit names, and the ONE function that applies it.

 *

 * Structural clone down the path only: every container on the way is copied, everything else is

 * shared. That keeps a 32 KB document's edit cheap AND keeps the old value untouched, which is

 * what makes the raw tab's Revert honest.

 *

 * ⚠ Returns the ORIGINAL root unchanged if the path does not resolve β€” a stale path (the raw tab

 * changed the shape under an open tree) must not invent the containers it names. The caller reads

 * that as "nothing to do" rather than silently growing the document a key it never had.

 */
function setAtPath(root: unknown, path: (string | number)[], next: unknown): unknown {
  if (path.length === 0) return next;
  const [head, ...rest] = path;
  if (Array.isArray(root)) {
    const i = typeof head === "number" ? head : Number(head);
    if (!Number.isInteger(i) || i < 0 || i >= root.length) return root;
    const copy = root.slice();
    copy[i] = setAtPath(root[i], rest, next);
    return copy;
  }
  if (root !== null && typeof root === "object") {
    const obj = root as Record<string, unknown>;
    const k = String(head);
    if (!Object.prototype.hasOwnProperty.call(obj, k)) return root;
    return { ...obj, [k]: setAtPath(obj[k], rest, next) };
  }
  return root;
}

/**

 * R13 β€” the in-place leaf editor.

 *

 * TYPE-PRESERVING by construction, which is the whole reason it is not one "edit as JSON" box:

 * a string edits as text (no quotes to type), a number as a number, a boolean as its two values.

 * A leaf therefore cannot change TYPE here β€” that is a shape change and belongs to the raw tab

 * (see the amendment at the top of the file).

 *

 * β›” A number that does not parse is REFUSED, not repaired and not coerced to a string. This is

 * decision 2 of the header applied one level down: quietly turning `12a` into `"12a"` teaches a

 * person their typo was fine, right up until a consumer of the document expects arithmetic.

 */
function LeafEditor({

  value,

  onCommit,

  onCancel,

}: {

  value: string | number | boolean;

  onCommit: (next: string | number | boolean) => void;

  onCancel: () => void;

}) {
  const kind = typeof value;
  const [text, setText] = useState(() => String(value));
  const [bad, setBad] = useState(false);
  const commit = () => {
    if (kind === "number") {
      const n = Number(text.trim());
      if (text.trim() === "" || !Number.isFinite(n)) {
        setBad(true);
        return;
      }
      onCommit(n);
      return;
    }
    onCommit(text);
  };
  if (kind === "boolean")
    return (
      <select

        className="cg-json-leafedit"

        autoFocus

        value={String(value)}

        aria-label="Value"

        onChange={(e) => onCommit(e.target.value === "true")}

        onBlur={onCancel}

      >

        <option value="true">true</option>

        <option value="false">false</option>

      </select>
    );
  return (
    <input

      className={"cg-json-leafedit" + (bad ? " is-bad" : "")}

      autoFocus

      value={text}

      spellCheck={false}

      aria-label="Value"

      aria-invalid={bad || undefined}

      title={bad ? "That is not a number β€” the value was not changed." : undefined}

      onChange={(e) => {

        setText(e.target.value);

        if (bad) setBad(false);

      }}

      onKeyDown={(e) => {

        if (e.key === "Enter") {

          e.preventDefault();

          commit();

        } else if (e.key === "Escape") {

          e.preventDefault();

          onCancel();

        }

      }}

      // Blur commits rather than discards: a person who clicks away from an edit box in a

      // document editor means "keep it" far more often than "throw it away", and Escape is the

      // discard that is actually reachable.

      onBlur={commit}

    />
  );
}

function Node({

  name,

  value,

  depth,

  last,

  path,

  onValue,

  editing,

  onEditing,

}: {

  name: string | null;

  value: unknown;

  depth: number;

  last: boolean;

  /** ⭐ R13 β€” where this node lives in the document, for `setAtPath`. */

  path: (string | number)[];

  /** ⭐ R13 β€” absent = the tree is READ-ONLY (the same condition `onSave` states one level up:

   *  a machine-owned, Odoo-sourced or unpermitted field). Present = leaves are editable. */

  onValue?: (path: (string | number)[], next: unknown) => void;

  /** The ONE leaf currently open for editing, as a joined path. Hoisted out of the node so

   *  opening a second editor closes the first β€” per-node state would leave a trail of open

   *  inputs down a document. */

  editing?: string;

  onEditing?: (key: string | undefined) => void;

}) {
  const [open, setOpen] = useState(depth < OPEN_TO_DEPTH);
  const pathKey = path.join("");
  if (!isBranch(value)) {
    const canEdit = !!onValue && value !== null;
    return (
      <div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>

        {name !== null && <span className="cg-json-key">{name}</span>}

        {name !== null && <span className="cg-json-punct">: </span>}

        {canEdit && editing === pathKey ? (

          <LeafEditor

            value={value as string | number | boolean}

            onCommit={(next) => {

              onValue(path, next);

              onEditing?.(undefined);

            }}

            onCancel={() => onEditing?.(undefined)}

          />

        ) : canEdit ? (

          // A real button, so the keyboard reaches every leaf the mouse does. `null` is

          // deliberately NOT editable: there is no type to preserve, so any edit would be a

          // shape change, which belongs to the raw tab.

          <button

            type="button"

            className="cg-json-leafbtn"

            onClick={() => onEditing?.(pathKey)}

            title="Edit this value"

          >

            <Leaf v={value} />

          </button>

        ) : (

          <Leaf v={value} />

        )}

        {!last && <span className="cg-json-punct">,</span>}

      </div>
    );
  }

  const array = Array.isArray(value);
  const entries: [string, unknown][] = array
    ? (value as unknown[]).map((v, i) => [String(i), v])
    : Object.entries(value as Record<string, unknown>);
  const openMark = array ? "[" : "{";
  const closeMark = array ? "]" : "}";

  return (
    <>

      <div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>

        <button

          type="button"

          className={"cg-json-twist" + (open ? " is-open" : "")}

          onClick={() => setOpen((o) => !o)}

          aria-expanded={open}

          aria-label={`${open ? "Collapse" : "Expand"} ${name ?? "the document"}`}

        >

          <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden>

            <path

              d="M6 4l4 4-4 4"

              fill="none"

              stroke="currentColor"

              strokeWidth="1.6"

              strokeLinecap="round"

              strokeLinejoin="round"

            />

          </svg>

        </button>

        {name !== null && <span className="cg-json-key">{name}</span>}

        {name !== null && <span className="cg-json-punct">: </span>}

        <span className="cg-json-punct">{openMark}</span>

        {/* Collapsed, the count IS the content β€” a bare `{…}` tells a reader

            nothing about whether opening it is worth the click. */}

        {!open && (

          <span className="cg-json-count">

            {entries.length} {array ? (entries.length === 1 ? "item" : "items")

              : entries.length === 1 ? "key" : "keys"}

          </span>

        )}

        {!open && <span className="cg-json-punct">{closeMark}{last ? "" : ","}</span>}

      </div>

      {open &&

        entries.map(([k, v], i) => (

          <Node

            key={k}

            name={array ? null : k}

            value={v}

            depth={depth + 1}

            last={i === entries.length - 1}

            path={[...path, array ? Number(k) : k]}

            onValue={onValue}

            editing={editing}

            onEditing={onEditing}

          />

        ))}

      {open && (

        <div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>

          <span className="cg-json-punct">

            {closeMark}

            {last ? "" : ","}

          </span>

        </div>

      )}

    </>
  );
}

// --- ⭐ wave-26 item 8 (contract C2): the POSTS reading ----------------------
//
// The owner's ask was that the `posts` cell "reads as POSTS, not raw JSON". The
// tree is a fine JSON reader and a poor post reader: `posts β†’ 3 β†’ caption` is
// four clicks to see one line somebody wrote. So a posts document opens on its
// OWN tab, and the tree and raw tabs stay exactly where they were β€” this adds a
// reading, it does not replace the document.
//
// β›” AN ABSENT METRIC RENDERS AS NOTHING, NEVER AS A ZERO. `postsWindowOf` keeps
// them `undefined` when unbought (C2: metrics are ABSENT KEYS when
// `postMetrics` was off), and the row below tests each one before painting it.
// A `0 views` on a post that was never measured is a fabricated measurement,
// and it is indistinguishable on screen from a real one.

/** One metric, or nothing at all. The guard IS the feature β€” see above. */
function Metric({ label, value }: { label: string; value: number | undefined }) {
  if (value === undefined) return null;
  return (
    <span className="cg-posts-metric">

      <b>{value.toLocaleString()}</b> {label}

    </span>
  );
}

function PostRow({ post }: { post: PostSummary }) {
  const day = post.posted_at ? dayText(post.posted_at) : "";
  /* ⚠ A ROW WITH NOTHING READABLE IS POSSIBLE BY CONSTRUCTION. `postsWindowOf` accepts any

     object inside `posts` β€” deliberately, because refusing an unrecognised member would hide a

     post the engine wrote in a shape this file has not learned yet. The engine's own writer

     fills these, so in practice it takes a hand-edited document to get here; when it happens the

     card must not paint as an empty rectangle, which reads as a rendering bug rather than as a

     thin record. The shortcode is the post's identity and is the honest last resort. */
  const bare = !day && !post.type && !post.caption && !post.url;
  return (
    <div className="cg-posts-row">

      {bare && (

        <span className="cg-posts-kind">{post.shortcode ?? "A post with no details"}</span>

      )}

      <div className="cg-posts-head">

        {day && <span className="cg-posts-day">{day}</span>}

        {/* The type is the post's own word (`reel`, `image`) β€” shown verbatim rather

            than mapped, because a vocabulary this file invents would drift from the

            vendor's the first time they add one. */}

        {post.type && <span className="cg-posts-kind">{post.type}</span>}

        {post.url && (

          <a

            className="cg-posts-link"

            href={post.url}

            target="_blank"

            rel="noopener noreferrer"

          >

            Open

          </a>

        )}

      </div>

      {post.caption && <p className="cg-posts-caption">{post.caption}</p>}

      {/* β›” NO `views` ROW HERE (2026-08-08 β€” instagram-capture.md Β§4e). The vendor's `views` is

          delivered at ACCOUNT grain: one value repeated across every reel of a creator. Rendering

          it beside this post's own likes and comments is exactly the reading that made it look

          like a post metric in the first place, and this panel would have kept saying so after

          the column was retired. `plays` is the honest per-reel field; it is omitted rather than

          shown empty, because a blank metric chip claims we looked and got zero. */}

      <div className="cg-posts-metrics">

        <Metric label="likes" value={post.likes} />

        <Metric label="comments" value={post.comments} />

      </div>

    </div>
  );
}

function PostsPanel({ window: w }: { window: PostsWindow }) {
  if (w.posts.length === 0)
    return <p className="cg-json-empty">No posts were captured for this profile.</p>;
  return (
    <div className="cg-posts">

      {/* β›” THE ONE SENTENCE THIS PANEL OWES A READER (DESIGN.md Β§4 β€” never over-explain).

          It is a WINDOW: the append tables keep the whole series (R1/R3), so a reader must not

          take these for every post the account has ever made. And when metrics were not bought,

          saying so is the only way an empty metrics row reads as "not measured" rather than

          "measured as nothing". */}

      <p className="cg-posts-note">

        The {w.posts.length} most recent post{w.posts.length === 1 ? "" : "s"}

        {w.as_of ? `, as of ${dayText(w.as_of)}` : ""}

        {w.metrics ? "." : " β€” engagement was not collected for this run."}

      </p>

      <ul className="cg-posts-list">

        {w.posts.map((p, i) => (

          <li key={p.shortcode ?? p.url ?? i} className="cg-posts-item">

            <PostRow post={p} />

          </li>

        ))}

      </ul>

    </div>
  );
}

// --- the shared modal shell -------------------------------------------------
//
// ⭐ WAVE-27 item 13 (R13) β€” extracted from `JsonViewer`'s own chrome when the CODE editor
// arrived, because "a big value that opens in a modal with a title, a summary line, a copy
// button and a footer" is now two things rather than one. One shell, so the two cannot drift
// into looking like different products β€” the reason `assetUrl` is one resolver, applied to
// furniture instead of to URLs.
//
// It owns ONLY the chrome. Tabs, body and footer are children: what a document IS differs
// between a JSON payload and a snippet, and a shell that tried to own that would end up with a
// `mode` flag threaded through every branch.

function ViewerShell({

  label,

  summary,

  kind,

  panelRef,

  titleId,

  onClose,

  onCopy,

  copied,

  children,

}: {

  label: string;

  summary: string;

  /** `data-overlay-kind` β€” the overlay layer's own identifier, distinct per viewer. */

  kind: string;

  panelRef: React.RefObject<HTMLDivElement>;

  titleId: string;

  onClose: () => void;

  onCopy: () => void;

  copied: boolean;

  children: ReactNode;

}) {

  return (

    <BodyPortal>

      <div className="cg-record-backdrop">

        <div

          className="cg-json-modal"

          ref={panelRef}

          role="dialog"

          aria-modal="true"

          aria-labelledby={titleId}

          data-overlay-kind={kind}

          tabIndex={-1}

        >

          <div className="cg-json-head">

            <div>

              <div className="cg-json-title" id={titleId}>{label}</div>

              <div className="cg-json-sub">{summary}</div>

            </div>

            <div className="cg-json-headacts">

              <button type="button" className="cg-json-btn" onClick={onCopy}>

                {copied ? "Copied" : "Copy"}

              </button>

              <button

                type="button"

                className="cg-icon-btn"

                onClick={onClose}

                aria-label="Close viewer"

              >

                Γ—

              </button>

            </div>

          </div>

          {children}

        </div>

      </div>

    </BodyPortal>

  );

}



// --- ⭐ WAVE-27 item 13 (R13): the CODE editor ------------------------------

//

// A snippet is not a document, and the difference decides everything below:

//

//  Β· IT NEED NOT PARSE. Half-written SQL is the normal state of a snippet, so there is no

//    validate-on-save and no refusal β€” the only limit is the byte cap the store enforces

//    anyway. This is the one rule `json` has that `code` deliberately does not.

//  Β· THERE IS NO TREE. A snippet has no structure to project, so there is one panel, not tabs.

//  Β· THE HIGHLIGHTER IS A RENDERER, NEVER AN INTERPRETER (R13: "NO execution engine"). It

//    colours strings, comments, numbers and a per-language keyword list. Nothing here can run,

//    fetch or evaluate anything, and no `language` value opens a path to something that could.

//

// The editor is a transparent-text `<textarea>` over a `<pre>` painted with the same text: the

// standard underlay, chosen over a contentEditable because a textarea keeps native undo, native

// selection and IME composition, all of which a hand-rolled editor loses on day one. The two

// layers MUST share font, size, line-height, padding and wrapping or the caret drifts from the

// glyphs β€” that pairing is stated in the CSS beside both rules, and the scroll sync below is the

// third leg of it.



interface Tok { t: string; c: string }



const LINE_COMMENT: Record<string, string[]> = {

  json: [], plain: [],

  sql: ["--"], python: ["#"], shell: ["#"], yaml: ["#"],

  javascript: ["//"], typescript: ["//"], css: [], html: [], xml: [], markdown: [],

};

const BLOCK_COMMENT: Record<string, [string, string] | undefined> = {

  javascript: ["/*", "*/"], typescript: ["/*", "*/"], css: ["/*", "*/"],

  sql: ["/*", "*/"], html: ["<!--", "-->"], xml: ["<!--", "-->"],

};

const KEYWORDS: Record<string, string[]> = {

  sql: ["select", "from", "where", "join", "left", "right", "inner", "outer", "on", "group",

    "order", "by", "having", "limit", "offset", "insert", "into", "values", "update", "set",

    "delete", "create", "table", "view", "as", "and", "or", "not", "null", "is", "in", "like",

    "between", "case", "when", "then", "else", "end", "distinct", "union", "all", "with"],

  python: ["def", "class", "return", "if", "elif", "else", "for", "while", "in", "not", "and",

    "or", "import", "from", "as", "try", "except", "finally", "raise", "with", "lambda", "None",

    "True", "False", "yield", "pass", "break", "continue", "global", "assert", "async", "await"],

  javascript: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "of",

    "in", "new", "class", "extends", "import", "export", "from", "default", "try", "catch",

    "finally", "throw", "async", "await", "typeof", "instanceof", "null", "undefined", "true",

    "false", "this", "switch", "case", "break", "continue", "delete", "void", "yield"],

  typescript: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "of",

    "in", "new", "class", "extends", "implements", "interface", "type", "enum", "import",

    "export", "from", "default", "try", "catch", "finally", "throw", "async", "await", "typeof",

    "instanceof", "null", "undefined", "true", "false", "this", "readonly", "public", "private",

    "protected", "as", "satisfies", "switch", "case", "break", "continue"],

  shell: ["if", "then", "else", "fi", "for", "in", "do", "done", "while", "case", "esac",

    "function", "return", "export", "local", "echo", "cd", "set", "source"],

  yaml: ["true", "false", "null"],

  json: ["true", "false", "null"],

  html: [], xml: [], css: [], markdown: [], plain: [],

};



const IDENT = /[A-Za-z_$][A-Za-z0-9_$]*/y;

const NUMBER = /\d+(\.\d+)?/y;



/**

 * R13 β€” the tokenizer. Single left-to-right pass, no backtracking, no regex over the whole

 * document: a 32 KB snippet must colour without blocking a keystroke.

 *

 * ⚠ EVERY branch consumes at least one character. A tokenizer that can advance zero characters

 * hangs the tab, and it is the one bug in this shape that cannot be seen by reading the output.

 */

export function tokenizeCode(text: string, lang: string): Tok[] {

  const out: Tok[] = [];

  const keywords = new Set(KEYWORDS[lang] ?? []);

  const lineMarks = LINE_COMMENT[lang] ?? [];

  const block = BLOCK_COMMENT[lang];

  const push = (t: string, c: string) => {

    if (!t) return;

    const prev = out[out.length - 1];

    if (prev && prev.c === c) prev.t += t;

    else out.push({ t, c });

  };

  let i = 0;

  while (i < text.length) {

    const ch = text[i];

    // Block comment

    if (block && text.startsWith(block[0], i)) {

      const end = text.indexOf(block[1], i + block[0].length);

      const stop = end < 0 ? text.length : end + block[1].length;

      push(text.slice(i, stop), "com");

      i = stop;

      continue;

    }

    // Line comment

    const mark = lineMarks.find((m) => text.startsWith(m, i));

    if (mark) {

      const nl = text.indexOf("\n", i);

      const stop = nl < 0 ? text.length : nl;

      push(text.slice(i, stop), "com");

      i = stop;

      continue;

    }

    // String β€” quotes close at the line end as well as at their pair, so one unterminated quote

    // cannot paint the rest of the file as a string (the failure every naive highlighter has).

    if (ch === '"' || ch === "'" || ch === "`") {

      let j = i + 1;

      while (j < text.length && text[j] !== ch && text[j] !== "\n") {

        if (text[j] === "\\") j += 1;

        j += 1;

      }

      const stop = j < text.length && text[j] === ch ? j + 1 : j;

      push(text.slice(i, stop), "str");

      i = stop;

      continue;

    }

    if (ch >= "0" && ch <= "9") {

      NUMBER.lastIndex = i;

      const m = NUMBER.exec(text);

      const t = m ? m[0] : ch;

      push(t, "num");

      i += t.length;

      continue;

    }

    if (/[A-Za-z_$]/.test(ch)) {

      IDENT.lastIndex = i;

      const m = IDENT.exec(text);

      const word = m ? m[0] : ch;

      push(word, keywords.has(word) || keywords.has(word.toLowerCase()) ? "kw" : "");

      i += word.length;

      continue;

    }

    push(ch, "");

    i += 1;

  }

  return out;

}



export interface CodeViewerProps {

  label: string;

  value: string;

  language: string;

  /** Absent = read-only, the same condition `JsonViewer` states. */

  onSave?: (next: string) => void;

  onClose: () => void;

}



export function CodeViewer({ label, value, language, onSave, onClose }: CodeViewerProps) {

  const panelRef = useRef<HTMLDivElement>(null);

  const preRef = useRef<HTMLPreElement>(null);

  const titleId = useId();

  // ⚠ `value`, not `shownValue` β€” a `code` cell is never thinned. Only `json` columns carry a

  // vendor document large enough to keep out of the list, so this component has nothing to fetch.

  const [draft, setDraft] = useState(value);

  const [error, setError] = useState("");

  const [copied, setCopied] = useState(false);



  // Re-seeds when the CELL changes underneath, and only then β€” the JsonViewer rule, same reason.

  useEffect(() => {

    setDraft(value);

    setError("");

  }, [value]);



  useOverlayLayer({

    panelRef,

    onDismiss: onClose,

    dismissOnOutside: true,

    initialFocus: "[data-overlay-autofocus]",

    trapFocus: true,

  });



  const toks = useMemo(() => tokenizeCode(draft, language), [draft, language]);

  const dirty = draft !== value;

  const bytes = jsonByteLength(draft);

  const lines = draft === "" ? 0 : draft.split("\n").length;

  const size = bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} KB`;

  const summary = draft.trim() === ""

    ? "Empty"

    : `${lines} line${lines === 1 ? "" : "s"} Β· ${size}`;



  const save = () => {

    // β›” NO parse check, and its absence is the design. A snippet that does not compile is the

    // normal state of one; refusing to save it would make the column useless for the thing it

    // exists for. The byte cap still applies, because the STORE enforces it either way and a

    // refusal the user only meets on the server is the one they cannot act on.

    if (bytes > MAX_JSON_BYTES) {

      setError(`That snippet is ${(bytes / 1024).toFixed(1)} KB and the limit is 32 KB β€” `

        + "nothing was saved.");

      return;

    }

    setError("");

    onSave?.(draft);

    onClose();

  };



  const copy = () => {

    void navigator.clipboard?.writeText(draft).then(

      () => {

        setCopied(true);

        window.setTimeout(() => setCopied(false), 1400);

      },

      () => setError("The browser refused clipboard access β€” select the text and copy it.")

    );

  };



  const painted = (

    <pre className="cg-code-paint" ref={preRef} aria-hidden>

      {toks.map((t, i) => (

        <span key={i} className={t.c ? `cg-code-${t.c}` : undefined}>{t.t}</span>

      ))}

      {/* A trailing newline has no glyph, so without this the last line scrolls out of step

          with the textarea's own extra row. */}

      {"\n"}

    </pre>

  );

  return (
    <ViewerShell

      label={label}

      summary={summary}

      kind="code-viewer"

      panelRef={panelRef}

      titleId={titleId}

      onClose={onClose}

      onCopy={copy}

      copied={copied}

    >

      <div className="cg-json-body">

        <div className="cg-code-wrap">

          {painted}

          {onSave ? (

            <textarea

              className="cg-code-input"

              value={draft}

              spellCheck={false}

              data-overlay-autofocus

              aria-label={`${label} source`}

              onChange={(e) => {

                setDraft(e.target.value);

                if (error) setError("");

              }}

              // The third leg of the underlay: scroll the paint with the text, or the colours

              // slide off the characters the moment the snippet is taller than the box.

              onScroll={(e) => {

                const el = preRef.current;

                if (!el) return;

                el.scrollTop = e.currentTarget.scrollTop;

                el.scrollLeft = e.currentTarget.scrollLeft;

              }}

            />

          ) : null}

        </div>

      </div>

      {(error || (onSave && dirty)) && (

        <div className="cg-json-foot">

          {error ? (

            <span className="cg-json-err">{error}</span>

          ) : (

            <span className="cg-json-hint">Unsaved changes</span>

          )}

          {onSave && (

            <span className="cg-json-footacts">

              <button

                type="button"

                className="cg-json-btn"

                onClick={() => {

                  setDraft(value);

                  setError("");

                }}

              >

                Revert

              </button>

              <button type="button" className="cg-json-btn cg-json-btn--primary" onClick={save}>

                Save

              </button>

            </span>

          )}

        </div>

      )}

    </ViewerShell>
  );
}

// --- the modal --------------------------------------------------------------

export interface JsonViewerProps {
  /** The column's own label β€” the modal's title. */
  label: string;
  /** The stored document, as a string. */
  value: string;
  /** Absent = read-only (the field is machine-owned, Odoo-sourced, or this

   *  viewer permits the reader nothing). Present = the raw tab is an editor. */
  onSave?: (next: string) => void;
  onClose: () => void;
}

export default function JsonViewer({ label, value, onSave, onClose }: JsonViewerProps) {
  /**

   * ⭐⭐ 2026-08-10 β€” THE DOCUMENT IS FETCHED HERE, not shipped with every row.

   *

   * MEASURED on nurilab: `source_payload` was **95.6%-98.5% of every IG grid's bytes** β€” 11.6 MB

   * of a 12.2 MB response on the post-measurement table β€” while the grid renders those cells as

   * a sixty-character preview. So the list now ships a stand-in and this modal, the one place a

   * person actually reads the document, asks for the real one.

   *

   * ⚠ THE STAND-IN CARRIES ITS OWN `_url`, so this component needs no table key and no record id

   * threaded down to it: the route that removed the value says where it went. One writer of that

   * address rather than a server rule and a client rule that must agree forever.

   * ⚠ A FETCH FAILURE LEAVES THE STAND-IN ON SCREEN rather than blanking the modal β€” it says the

   * document exists and is `bytes` long, which is strictly more than an empty box tells you.

   */
  const [fetched, setFetched] = useState<string | null>(null);
  useEffect(() => {
    setFetched(null);
    let live = true;
    let url = "";
    try {
      const doc = JSON.parse(value) as { _truncated?: boolean; _url?: string };
      if (doc && doc._truncated === true && typeof doc._url === "string") url = doc._url;
    } catch {
      /* not a stand-in β€” an ordinary document renders as it always has */
    }
    if (!url) return;
    void fetch(url, { credentials: "include" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => {
        if (live && j && typeof j.value === "string") setFetched(j.value);
      })
      .catch(() => undefined);
    return () => {
      live = false;
    };
  }, [value]);
  const shownValue = fetched ?? value;
  const panelRef = useRef<HTMLDivElement>(null);
  const titleId = useId();
  const tabsId = useId();
  /** `null` = nobody has chosen one, so the landing tab is derived (see `shown`). */
  const [picked, setPicked] = useState<"posts" | "tree" | "raw" | null>(null);
  const [draft, setDraft] = useState(shownValue);
  const [error, setError] = useState("");
  const [copied, setCopied] = useState(false);

  // ⚠ The draft re-seeds when the CELL changes underneath (a machine write
  // landing while the viewer is open), and only then β€” keying on the raw prop
  // alone would also stamp on every keystroke's re-render if a parent echoed it back.
  //
  // β›” `shownValue`, NOT `value`, AND THAT ONE WORD WAS THE WHOLE BUG. Everything this modal
  // renders comes from `draft` (`parsed = jsonParse(draft)`), so seeding it once from the prop
  // meant the fetched document arrived, sat in `fetched`, and was never shown: the viewer kept
  // displaying the `{"_truncated": …}` stand-in while the network tab showed a 200 with the real
  // payload. MEASURED live β€” the request fired, succeeded, and changed nothing on screen, which
  // is the most misleading shape a fix can have.
  useEffect(() => {
    setDraft(shownValue);
    setError("");
  }, [shownValue]);

  useOverlayLayer({
    panelRef,
    onDismiss: onClose,
    dismissOnOutside: true,
    initialFocus: "[data-overlay-autofocus]",
    trapFocus: true,
  });

  const parsed = useMemo(() => jsonParse(draft), [draft]);
  const dirty = draft !== shownValue;
  /** ⭐ R13 β€” which leaf is open for editing, as a joined path. ONE at a time (see `Node`). */
  const [editingLeaf, setEditingLeaf] = useState<string | undefined>(undefined);
  /**

   * ⭐ R13 β€” a tree edit, applied to the RAW TEXT.

   *

   * This is what makes "two-way synced, raw wins" true without a reconciliation rule: the tree

   * never holds a document of its own. It re-parses the current draft, writes one value, and

   * serialises the whole thing back β€” so the raw tab and the tree are always literally the same

   * string, and the next render's tree comes from that string rather than from what the tree

   * thought it did.

   *

   * ⚠ Re-parsed HERE rather than reusing the `parsed` memo: between a click and a commit the raw

   * tab may have changed the document, and `setAtPath` returning the root unchanged for a path

   * that no longer resolves is the guard that keeps a stale edit from inventing keys.

   *

   * ⚠ `jsonPretty` reformats the WHOLE document, so a hand-indented payload is normalised by a

   * tree edit. Stated rather than worked around: the alternative is a surgical text splice, which

   * needs a position map from the parse β€” and a wrong splice writes a value into the wrong key.

   */
  const editLeaf = (path: (string | number)[], next: unknown) => {
    const now = jsonParse(draft);
    if (!now.ok) return;
    const updated = setAtPath(now.value, path, next);
    if (updated === now.value) return;      // stale path β€” nothing was written
    setDraft(jsonPretty(JSON.stringify(updated)));
    if (error) setError("");
  };
  /* ⭐ Item 8 β€” is this document a POSTS WINDOW? Read off the DRAFT, so editing the raw tab into

     (or out of) the shape moves the tab with it rather than stranding a Posts tab over a

     document that is no longer one. */
  const posts = useMemo(
    () => (parsed.ok ? postsWindowOf(parsed.value) : null),
    [parsed]
  );
  /* The tab set is DERIVED from the document, and the shown tab is derived from the set β€” never

     corrected by an effect. Two things fall out of that, both of which an effect gets wrong:



     ⚠ POSTS IS THE LANDING TAB when the document is one, which is the whole of the owner's ask

       ("reads as POSTS, not raw JSON"). A `useState` initialiser cannot do it: the cell value

       arrives asynchronously and can change under an open viewer, so it would seed "tree" for

       exactly the render that matters and the reader would meet the JSON first.

     ⚠ A tab the reader PICKED wins while it still exists β€” and stops winning the moment it does

       not. Editing a posts document into something else on the raw tab would otherwise leave the

       panel rendering a Posts tab that is no longer in the list above it. */
  const tabs = posts ? (["posts", "tree", "raw"] as const) : (["tree", "raw"] as const);
  const shown =
    picked && (tabs as readonly string[]).includes(picked) ? picked : posts ? "posts" : "tree";

  const save = () => {
    const text = draft.trim();
    // Blank is a legal edit: it clears the cell, which is how a document is
    // removed. Everything else must parse before it is allowed to leave here.
    if (text !== "") {
      if (!jsonParse(text).ok) {
        setError("That is not valid JSON β€” nothing was saved. Check for a trailing comma, a "
          + "single quote, or an unquoted key.");
        return;
      }
      const bytes = jsonByteLength(text);
      if (bytes > MAX_JSON_BYTES) {
        setError(`That document is ${(bytes / 1024).toFixed(1)} KB and the limit is 32 KB β€” `
          + "nothing was saved.");
        return;
      }
    }
    setError("");
    onSave?.(text);
    onClose();
  };

  const copy = () => {
    void navigator.clipboard?.writeText(draft).then(
      () => {
        setCopied(true);
        window.setTimeout(() => setCopied(false), 1400);
      },
      () => setError("The browser refused clipboard access β€” select the raw text and copy it.")
    );
  };

  return (
    <ViewerShell

      label={label}

      /* The summary is the ONE explanatory line (DESIGN.md Β§4): what this document is, not

         what a JSON field is for. */

      summary={jsonSummary(draft)}

      kind="json-viewer"

      panelRef={panelRef}

      titleId={titleId}

      onClose={onClose}

      onCopy={copy}

      copied={copied}

    >



          <div className="cg-record-tabs" role="tablist" aria-label="Document view">

            {tabs.map((k) => (

              <button

                key={k}

                type="button"

                role="tab"

                id={`${tabsId}-${k}`}

                aria-selected={shown === k}

                aria-controls={`${tabsId}-panel`}

                className={"cg-record-tab" + (shown === k ? " is-on" : "")}

                onClick={() => setPicked(k)}

                data-overlay-autofocus={k === tabs[0] ? true : undefined}

              >

                {k === "posts" ? "Posts" : k === "tree" ? "Tree" : "Raw"}

              </button>

            ))}

          </div>



          <div

            className="cg-json-body"

            id={`${tabsId}-panel`}

            role="tabpanel"

            aria-labelledby={`${tabsId}-${shown}`}

          >

            {shown === "posts" && posts ? (

              <PostsPanel window={posts} />

            ) : shown === "tree" ? (

              parsed.ok ? (

                <div className="cg-json-tree">

                  <Node

                    name={null}

                    value={parsed.value}

                    depth={0}

                    last

                    path={[]}

                    /* ⭐ R13 β€” the tree edits under exactly the condition the raw tab does.

                       `onSave` absent already means "this reader may not change the value", and

                       giving the tree a second, looser answer to that question is how one door

                       keeps taking writes after the other stops. */

                    onValue={onSave ? editLeaf : undefined}

                    editing={editingLeaf}

                    onEditing={setEditingLeaf}

                  />

                </div>

              ) : (

                // β›” NOT an empty tree and NOT a repaired one. A document the app

                // cannot open is a fact about the value, and the raw tab is where

                // it gets read β€” so the empty state POINTS THERE rather than

                // describing what JSON is.

                <p className="cg-json-empty">

                  {draft.trim() === ""

                    ? "Nothing here yet."

                    : "This value is not valid JSON β€” read it on the Raw tab."}

                </p>

              )

            ) : onSave ? (

              <textarea

                className="cg-json-raw"

                value={draft}

                spellCheck={false}

                onChange={(e) => {

                  setDraft(e.target.value);

                  if (error) setError("");

                }}

                aria-label={`${label} raw JSON`}

              />

            ) : (

              <pre className="cg-json-raw cg-json-raw--ro">{jsonPretty(draft)}</pre>

            )}

          </div>



          {(error || (onSave && dirty)) && (

            <div className="cg-json-foot">

              {error ? (

                <span className="cg-json-err">{error}</span>

              ) : (

                <span className="cg-json-hint">Unsaved changes</span>

              )}

              {onSave && (

                <span className="cg-json-footacts">

                  <button

                    type="button"

                    className="cg-json-btn"

                    onClick={() => {

                      setDraft(value);

                      setError("");

                    }}

                  >

                    Revert

                  </button>

                  <button type="button" className="cg-json-btn cg-json-btn--primary" onClick={save}>

                    Save

                  </button>

                </span>

              )}

            </div>

          )}

    </ViewerShell>
  );
}