File size: 35,828 Bytes
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a878ebb
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d349fee
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
f5ffed6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d349fee
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d349fee
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d349fee
 
 
 
 
 
 
 
 
 
 
 
091a823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
091a823
dcdb685
 
 
 
 
 
 
c3e4cb4
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d349fee
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5ffed6
 
 
 
 
d349fee
 
 
 
c3e4cb4
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a878ebb
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// ---------------------------------------------------------------------------
// customer-grid / iconShapes.ts
// Wave-8 items I18 + I20 β€” ONE geometry source for the grid's icon vocabulary,
// rendered by TWO very different painters:
//
//   - React  <FieldTypeIcon> / <ModeIcon>  β€” DOM svg in panels, popovers, menus
//   - glide  headerIcons sprites           β€” canvas, drawn from an SVG *string*
//
// Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
// never be a React component. Keeping the paths as DATA (IconShape[]) and giving
// each painter its own thin renderer is what stops the two from drifting β€” the
// alternative (hand-copying every path into a template literal) guarantees the
// header and the panel eventually disagree about what a "date" looks like.
//
// Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
// currentColor. Vector paths only β€” NEVER emoji (owner constant).
// ---------------------------------------------------------------------------

import type { AggName, DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
import {
  LP_BLUE,
  LP_BLUE_DEEP,
  LP_GREEN,
  LP_GREEN_DEEP,
  LP_LINE,
  LP_MUTED,
  LP_RED,
  LP_RED_DEEP,
  LP_YELLOW,
  LP_YELLOW_DEEP,
} from "./theme";

/** One drawing primitive. `fill: true` fills the path instead of stroking it
 *  (the rating star is the only shape that reads better solid). */
export type IconShape = { d: string; fill?: boolean };

/** A circle as a path β€” two half-arcs. Sprites are SVG *source*, so every shape
 *  has to survive being serialized into a string; paths do, <circle> elements
 *  would need a second serializer branch for no benefit. */
const circle = (cx: number, cy: number, r: number): string =>
  `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;

const CALENDAR: IconShape[] = [
  { d: "M3.2 4.6h9.6v8.2H3.2z" },
  { d: "M3.2 7.2h9.6" },
  { d: "M5.8 3v3.2" },
  { d: "M10.2 3v3.2" },
];

/**
 * Field type β†’ icon geometry. A TOTAL record on purpose: adding a FieldType
 * without an icon is a compile error, not a silently blank header.
 */
export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
  text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
  status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
  currency: [
    { d: "M8 2.8v10.4" },
    { d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
  ],
  int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
  date: CALENDAR,
  pct: [
    { d: circle(4.6, 4.6, 1.6) },
    { d: circle(11.4, 11.4, 1.6) },
    { d: "M12.2 3.9L3.8 12.3" },
  ],
  select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
  user: [
    { d: circle(8, 6, 2.4) },
    { d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
  ],
  multiselect: [
    { d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
    { d: "M7.2 5.7h6M7.2 10.3h6" },
  ],
  checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
  phone: [
    { d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
  ],
  email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
  url: [
    { d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
    { d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
    { d: "M6.2 9.8l3.6-3.6" },
  ],
  rating: [
    {
      d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
      fill: true,
    },
  ],
  created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
  formula: [
    { d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
    { d: "M4.2 7.6h4.6" },
    { d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
  ],
  // Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
  // with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
  // Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
  // (`created_time` owns the closed rim + hands).
  automation: [
    { d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
    { d: "M4.2 6L5.3 4.1 3.1 4.5" },
    { d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
  ],
  // Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
  // series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
  // the formula fx (that computes over the ROW) and not a bare number (int owns ##).
  metric: [
    { d: "M3.2 3.2v9.6h9.6" },
    { d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
  ],
  // Wave-23 C7 β€” TWO BRACES facing each other with a dot between them: the universal mark for
  // "a structured document", and the one glyph in this table that draws its own SYNTAX rather
  // than a picture of what the value means. Deliberately not a document page (nothing here owns
  // that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
  // of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
  // reading as parentheses at small sizes.
  json: [
    { d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
    { d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
    { d: circle(8, 8, 0.85), fill: true },
  ],
  // ⭐ Wave-27 item 13 (R13) β€” THE ANGLE BRACKETS, the mark every editor on earth uses for
  // "this is source". Drawn as two chevrons with a slash leaning between them, which is what
  // separates it from `json` two entries up: json draws BRACES (a document's own syntax), code
  // draws BRACKETS (a snippet's). Deliberately not a terminal prompt (that reads as "run this",
  // and R13 is explicit there is no execution engine) and not a page of lines (`list` mode and
  // `text` already trade on that reading).
  code: [
    { d: "M5.6 4.9 2.6 8l3 3.1" },
    { d: "M10.4 4.9 13.4 8l-3 3.1" },
    { d: "M9.1 3.6 6.9 12.4" },
  ],
  // Wave-19 R7 β€” a framed picture: the mount, a sun, and the hill line every photo glyph
  // resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
  image: [
    { d: "M2.6 3.4h10.8v9.2H2.6z" },
    { d: circle(6, 6.3, 1.1) },
    { d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
  ],
  // ⭐ 2026-08-07 β€” TWO INTERLOCKING CHAIN LINKS, the one glyph everybody already reads as
  // "this points at something else". Drawn as two rounded rectangles overlapping at the centre
  // rather than as an arrow into a box: an arrow would mean navigation, and a link column is a
  // relation that exists in both directions whether or not you follow it.
  link: [
    { d: "M6.6 5.2H4.9a2.8 2.8 0 000 5.6h1.7" },
    { d: "M9.4 5.2h1.7a2.8 2.8 0 010 5.6H9.4" },
    { d: "M5.6 8h4.8" },
  ],
  // ⭐ 2026-08-07 β€” THREE BARS FOLDING INTO ONE, read top-to-bottom: many linked values
  // collapsing to a single aggregate. Deliberately not a sigma (too fine at 16px, and it would
  // claim SUM when the function is chosen per column) and not a funnel (that is filtering,
  // which is what `limit` does β€” a different half of the same field).
  rollup: [
    { d: "M3.2 4.4h9.6" },
    { d: "M4.8 8h6.4" },
    { d: "M6.6 11.6h2.8" },
  ],
};

/**
 * Display mode β†’ icon geometry (I18). Also total: the wave-8 Dashboard mode
 * cannot land in DISPLAY_MODES without the compiler demanding its icon here.
 */
export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
  grid: [
    { d: "M2.6 3.4h10.8v9.2H2.6z" },
    { d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
  ],
  list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
  // I19c β€” a framed set of bars: "several charts", not "one chart".
  chart: [
    { d: "M2.6 3.4h10.8v9.2H2.6z" },
    { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
  ],
  // I10 (C2) β€” the LEGACY key. Kept so this record stays total over DisplayMode, which is
  // what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
  // promise. Same drawing: a stored 'dashboard' IS a chart.
  dashboard: [
    { d: "M2.6 3.4h10.8v9.2H2.6z" },
    { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
  ],
  calendar: CALENDAR,
  kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
  map: [
    { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
    { d: circle(8, 6.1, 1.3) },
  ],
  // 2026-08-02 item 7 β€” the time-series view. Drawn as a framed grid with a trend running
  // through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
  // a per-row line toggle. Deliberately not the plain `line` chart mark β€” a chart view and a
  // time-series table must not be the same picture in the same rail.
  timeseries: [
    { d: "M2.6 3.4h10.8v9.2H2.6z" },
    { d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
    { d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
  ],
  // Wave-18 C6-CATALOG β€” an OPEN BOOK: two facing leaves with a spine between them, and a
  // product block sitting on the left one. Every other mode in this table draws a way of
  // arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
  // with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
  // not a framed grid (`grid`/`chart`/`timeseries` share the frame).
  catalog: [
    { d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
    { d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
    { d: "M3.9 6.4h2.3v2.8H3.9z" },
  ],
  // Wave-23 C9 β€” a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
  // them. Every other mode here draws a way of ARRANGING records that already exist; this one
  // has to read as a record being MADE, so it is the only mark whose bottom line is open.
  // Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
  // (an edit affordance means something else app-wide).
  // ⭐ Wave-27 C3 (owner item 8) β€” a CARD WITH TWO ARROWS LEAVING IT, left and right. Every
  // other mark here draws an arrangement of many records; this one has to read as ONE record
  // with two exits, because that is exactly what the deck is. Deliberately not a stack of cards
  // (that is a lane, and `kanban` owns it) and not a hand or a gesture glyph (nothing else in
  // this vocabulary draws a body part, and it would read as "drag" rather than "decide").
  swipe: [
    { d: "M5.4 3.4h5.2v9.2H5.4z" },
    { d: "M3.6 8H1.4M2.8 6.8 1.4 8l1.4 1.2" },
    { d: "M12.4 8h2.2M13.2 6.8 14.6 8l-1.4 1.2" },
  ],
  form: [
    { d: "M3.4 2.8h9.2v10.4H3.4z" },
    { d: "M5.6 5.6h4.8" },
    { d: "M5.6 8h4.8" },
    { d: "M5.6 10.6h2.6" },
  ],
};

/**
 * Wave-9 I16 β€” one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
 * vocabulary cannot ship without a drawing.
 *
 * ⚠ The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
 * an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
 * picker is NOT a native `<select>` β€” it is a radio-row list like the mode switcher, which
 * is the only shape that can carry a real mark.
 *
 * The key type is a string union declared here rather than imported from chartData.ts: this
 * module is a leaf (types + theme only) and chartData imports IT, not the reverse.
 */
export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
  bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
  line: [
    { d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
    { d: circle(5.8, 7.8, 0.9) },
    { d: circle(8.4, 9.8, 0.9) },
  ],
  area: [
    { d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
    { d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
  ],
  donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
  // A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
  // reads as "one number" beside four marks that all read as "a distribution".
  kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
  // Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
  // values, not a distribution), with a header band and a column rule.
  table: [
    { d: "M2.6 3.4h10.8v9.2H2.6z" },
    { d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
  ],
};

export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
  bar: "Bar",
  line: "Line",
  area: "Area",
  donut: "Donut",
  kpi: "Single value",
  table: "Table",
};

/** I16 β€” the pastel each chart kind wears, same family rule as MODE_TONE. */
export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
  bar: "blue",
  line: "green",
  area: "green",
  donut: "yellow",
  kpi: "neutral",
  table: "neutral",
};

/**
 * Wave-9 contract C5 (I15) β€” folder icon geometry. TOTAL over `FolderShape`, so a shape key
 * added to the wire contract in types.ts cannot ship without a drawing.
 *
 * Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
 * the same rail and read as one family. `folder` is first because it is the default every
 * pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
 */
export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
  folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
  star: [
    { d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
  ],
  flag: [
    { d: "M4.2 13.4V2.9" },
    { d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
  ],
  tag: [
    { d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
    { d: circle(5.6, 5.6, 1) },
  ],
  bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
  // Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
  // Reusing the geometry rather than drawing a second "chart" is the whole point of the
  // one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
  grid: MODE_SHAPES.grid,
  chart: MODE_SHAPES.dashboard,
  map: MODE_SHAPES.map,
  users: TYPE_SHAPES.user,
  clock: TYPE_SHAPES.created_time,
  heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
  bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
};

/**
 * Tone key β†’ the pastel it FILLS with, and the -d weight it STROKES with.
 *
 * Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
 * that a base pastel at that size smudges β€” LP_BLUE measures 1.88:1 on white. So the pastel
 * is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
 * outline (an outline that reads at all).
 *
 * The default tone is `neutral` β€” HOST's C5 key, not "grey". The whitelist is SHARED between
 * the two ends, so the name matters more than the word: a tone the host does not recognise
 * degrades to the default and the user's choice silently disappears on reload.
 */
export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
  neutral: { fill: LP_LINE, stroke: LP_MUTED },
  blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
  green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
  yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
  red: { fill: LP_RED, stroke: LP_RED_DEEP },
};

export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
  neutral: "Neutral",
  blue: "Blue",
  green: "Green",
  yellow: "Yellow",
  red: "Red",
};

export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
  folder: "Folder",
  star: "Star",
  flag: "Flag",
  tag: "Tag",
  bookmark: "Bookmark",
  grid: "Table",
  chart: "Chart",
  map: "Map",
  users: "People",
  clock: "Clock",
  heart: "Heart",
  bolt: "Priority",
};

/**
 * Wave-9 I14 β€” the tone each CREATABLE view type wears in the "+ Create new…" flyout.
 *
 * The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
 * is a list you read rather than scan. Assigned by family, not by rotation: the two
 * record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
 * share yellow, chart is green because it is the analytical one, map is red because it is
 * the geographic one. Folder is grey β€” it is not a view, and the flyout's last row should
 * not compete with the six above it.
 */
/**
 * Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
 * sits beside the geometry, the way TYPE_LABELS does β€” the mode switcher, the create flyout
 * and the create prompt now read ONE table instead of three. C2's "Dashboard" β†’ "Chart"
 * rename is a single line here as a direct result.
 */
export const MODE_LABELS: Record<DisplayMode, string> = {
  grid: "Grid",
  chart: "Chart",
  // Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
  // read before normalisation must never render a blank switcher chip.
  dashboard: "Chart",
  list: "List",
  calendar: "Calendar",
  kanban: "Kanban",
  map: "Map",
  timeseries: "Time series",
  catalog: "Catalog",
  // Wave-23 C9 β€” the mode that COLLECTS records. "Form", the word the whole product uses for
  // it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
  // be the one surface calling it something else.
  form: "Form",
  // ⭐ Wave-27 C3 (item 8) β€” the owner's own word for it. Not "Triage" or "Review": the gesture
  // IS the name here, and the two candidates both collide with vocabulary this product already
  // spends elsewhere (a `review` automation decision, the retired review lanes).
  swipe: "Swipe",
};

/**
 * I14 β€” the view types the "+ Create new…" flyout OFFERS, in the order it lists them.
 *
 * Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
 * C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
 * rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β€” one list cannot
 * express both. Living in this pure data module means the gate can assert the offered set
 * without importing a React component, and I10 becomes a one-line edit in one file.
 */
// 2026-08-02 item 7 β€” `timeseries` was deliberately held OUT of this list until the host
// accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
// wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
// not know, so offering it early would let a user create a view that silently reverts to a
// grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
// `DISPLAY_MODES += timeseries`, so it is offerable now.
//
// wave17 GRID, owner item 10 β€” THE ORDER BELOW IS THE OWNER'S, stated verbatim:
// Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
//
// ⚠ It supersedes the two orderings this list has carried before it, and the reasoning that
// produced them is now WRONG rather than merely outranked, so it is not left here to be
// re-applied: `timeseries` was "listed last… it belongs beside Chart", and `list` sat second
// as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
// a product decision, so it is asserted in `verify_icons` rather than left to a comment β€”
// nothing else on screen would go red if a future edit re-sorted it "sensibly".
// Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
// accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
// "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
// It lands LAST by contract: the owner's seven-mode order above is a product decision and the
// new mode joins the end of it rather than being sorted into it.
// ⭐ Wave-27 C3 (owner item 8) β€” `swipe` is HELD OUT of this list, and the reason corrects a
// mistake this file made an hour earlier.
//
// β›” THE HOLD WAS NEVER ONLY ABOUT THE TWO REGISTRIES. It was first written as "do not offer a
// mode the HOST has not accepted", and one session owning both registries this wave genuinely
// does close that half β€” which is what made it tempting to skip. But the rule the hold really
// encodes is broader and this wave proved it: **do not offer a mode whose CONSUMER does not
// exist.** `swipe` was briefly listed here while `CustomerGrid`'s mode dispatch had no branch
// for it, so picking "Swipe" wrote a mode the host now happily PERSISTS, and the body rendered
// a grid under a chip reading "View Β· Swipe" β€” surviving reload, with the agreement leg green
// because it only ever compared two lists.
//
// So the hold stood until `SwipeView` was mounted (contract C3), and it was not a mailbox
// handshake: `verify_icons` DERIVES the condition β€” either `swipe` is absent here, or
// `CustomerGrid.tsx` mounts `SwipeView`. `form` (wave-23) is the same defect from the other
// direction, sitting unoffered because its host mirror never landed (DEBT D-90).
//
// β›” THE HOLD OUTLIVED THE WAVE, AND THAT IS THE LESSON WORTH MORE THAN THE FEATURE.
// Wave 27 closed with the mount NEVER LANDING. `SwipeView.tsx` shipped, the server accepted
// `swipe`, all four maps above carried it, 50 gates were green, the wave-27 mailbox recorded
// "RESOLVED BY C (swipe mounted)", the close-out booked D-102 β€” a negative control FOR the
// swipe carry β€” and the owner could not find the view because **nothing imported the file.**
// The derived gate could not catch it: its condition is a DISJUNCTION and **absence satisfies
// it**, so the unshipped state was permanently green ([[gate-can-report-green-on-nothing]]).
// A hold that is safe to leave in place is a hold nothing forces you to lift.
// The audit query that found it, after the battery did not: for each artifact a wave adds, grep
// for its CONSUMER β€” who imports/mounts/registers it β€” excluding the file itself, `_test/`, css
// and comments. `SwipeView` had four hits and three were prose.
// Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
// It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new
// mode joins the end of it rather than being sorted into it.
export const CREATABLE_MODES: DisplayMode[] = [
  "grid",
  "chart",
  "calendar",
  "kanban",
  "timeseries",
  "map",
  "list",
  "catalog",
  "swipe",
  // Mounted 2026-08-11 (`CustomerGrid.tsx`, `displayMode === "form"` renders `FormInterface`), so
  // `form` is offerable and its `HELD_MODES` entry came out in the same edit β€” the hold's own text
  // named this mount as its release condition. ⚠ Found by `verify_icons.py::mode_parity` law E
  // during /validate-wave, NOT by the lane that mounted it: T29 mounted the component and T41 built
  // the Interface group, and between them the DOOR was never opened β€” the wave's biggest new
  // surface was unreachable behind two green tickets.
  "form",
];

/**
 * ⭐ WAVE-29 R6 (owner item 10) β€” the kind dropdown splits under TWO headers, and the strings are
 * the owner's own: exactly `View` and `Interface`. Not "View as" (what the popover said before),
 * not "Custom interface".
 *
 * The line the two groups draw: a **View** ARRANGES the records β€” the same rows, re-shaped (a
 * grid, a board, a deck, a chart). An **Interface** is a SURFACE BUILT OVER them: a map is a
 * picture of the world with records placed on it, a catalog is a published artifact, a form is a
 * door records come IN through and shows no records at all. It is the same distinction
 * `MODE_TONE` below already draws by colour β€” the four tones name families of ways to arrange
 * records, and `catalog`/`form` are NEUTRAL there precisely because they arrange nothing.
 *
 * ⚠ TOTAL over `DisplayMode`, so tsc refuses a new mode with no group rather than letting it
 * vanish from the dropdown: membership is derived by FILTERING `CREATABLE_MODES` through this
 * map, and a mode whose group label matched nothing would silently stop being offered while
 * every existing check (paintable, labelled, toned, unique, ordered) stayed green.
 * `dashboard` is grouped like the `chart` it is the legacy spelling of β€” it is never offered, and
 * a partial map is a worse answer than an unused entry.
 */
export const MODE_GROUP_LABELS = ["View", "Interface"] as const;
export type ModeGroup = (typeof MODE_GROUP_LABELS)[number];

export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
  grid: "View",
  list: "View",
  kanban: "View",
  calendar: "View",
  chart: "View",
  dashboard: "View",
  timeseries: "View",
  swipe: "View",
  map: "Interface",
  catalog: "Interface",
  form: "Interface",
};

/**
 * The offered modes, split into R6's two groups β€” DERIVED, never a third hand-written list.
 *
 * ⚠ ORDER: R6 names each group's MEMBERS; the order inside a group stays `CREATABLE_MODES`', which
 * is the wave-17 owner ruling ("Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List") and
 * is separately asserted. The two rulings are compatible read this way and only this way: R6 moved
 * `map` out of the run of views, so wave-17's single sequence can no longer exist as one list, but
 * every pair it ordered is still in that relative order here.
 */
export const CREATABLE_GROUPS: readonly { label: ModeGroup; modes: DisplayMode[] }[] =
  MODE_GROUP_LABELS.map((label) => ({
    label,
    modes: CREATABLE_MODES.filter((m) => MODE_GROUP[m] === label),
  }));

export const MODE_TONE: Record<DisplayMode, FolderTone> = {
  grid: "blue",
  list: "blue",
  chart: "green",
  dashboard: "green",
  calendar: "yellow",
  kanban: "yellow",
  map: "red",
  // Green with `chart`: it is the other analytical mode, and the two belong to one family.
  timeseries: "green",
  // Wave-18 C6-CATALOG β€” NEUTRAL, and it is the honest pick rather than the leftover one. The
  // four colour tones each name a family of ways to arrange records (blue = tabular, green =
  // analytical, yellow = board/date, red = spatial); a catalog arranges nothing β€” it is a
  // published artifact. Giving it a colour would file it under a family it is not in.
  catalog: "neutral",
  // Wave-23 C9 β€” NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
  // name families of ways to ARRANGE records (blue tabular, green analytical, yellow
  // board/date, red spatial). A form arranges nothing β€” it is a door records come in through β€”
  // so giving it a colour would file it under a family it is not in.
  form: "neutral",
  // ⭐ Wave-27 C3 β€” YELLOW, with `kanban`, and this is a family claim rather than a leftover:
  // a swipe deck writes the SAME single-select a kanban stacks by (R2 binds it to one), so the
  // two are one family seen at two zooms β€” all the lanes at once, or one card at a time. Filing
  // it neutral (the `catalog`/`form` reasoning) would be wrong for the opposite reason those
  // two are neutral: this mode does arrange records, and it arranges them by the board's field.
  swipe: "yellow",
};

/**
 * Human labels for every field type. Lives here beside the icons so the two
 * halves of "how a field type presents itself" stay in one file (ColumnMenu
 * imports it rather than keeping a second copy).
 */
export const TYPE_LABELS: Record<FieldType, string> = {
  // ⭐ WAVE-29 item 3 β€” the owner's own words: "Change 'Single line text' to 'Text', keep it
  // simple for the Field type". Airtable's phrase described the column's SHAPE (one line, versus
  // its long-text sibling); this product has no multi-line text kind, so the qualifier
  // distinguished the type from nothing and only made the commonest row in the menu the longest.
  // βœ… The STORED key is `"text"` and always was β€” the old string was never persisted anywhere,
  // client or server, so this is a label change with no migration behind it.
  text: "Text",
  select: "Single select",
  multiselect: "Multi select",
  user: "Assignee",
  int: "Number",
  currency: "Currency",
  pct: "Percent",
  date: "Date",
  checkbox: "Checkbox",
  phone: "Phone number",
  email: "Email",
  url: "URL",
  rating: "Rating",
  created_time: "Created time",
  formula: "Formula",
  // Wave-18 C5-AUTOFIELD (D's spec, applied by C).
  automation: "Automation",
  // Wave-22 C7 β€” spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
  // on headers and the field gear, not the create menu.
  metric: "Metric",
  // Wave-19 R7 β€” the picture column.
  image: "Image",
  // Wave-23 C7 β€” the structured-document column. "JSON" rather than "Structured data": it is
  // the word on the wire, in the viewer's raw tab and in every error the server can return, and
  // a friendlier synonym would be the only place in the product using a different one.
  json: "JSON",
  // ⭐ 2026-08-07 β€” Airtable's own wording, deliberately. "Link to another record" is what a
  // person migrating from Airtable searches this menu for, and inventing a synonym ("Relation",
  // "Reference") would make the feature they came for look absent.
  link: "Link to another record",
  rollup: "Rollup",
  // ⭐ Wave-27 item 13 (R13) β€” "Code", not "Snippet" or "Source": it is the word the field kind
  // is called everywhere else in this wave (the ruling, the language picker, the viewer header),
  // and it says what the column holds without implying the product will run it.
  code: "Code",
  status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
};

/**
 * ⭐ WAVE-29 C7 (item 17) β€” THE COLUMN-SUMMARY vocabulary: what a field's `agg` may be, which is
 * what the totals row and the per-group subtotals compute. Server twin:
 * `platform/aios_grid.py::FIELD_AGGS`, and `verify_icons.py::agg_parity` reads BOTH FILES and
 * compares them name-for-name in order β€” the cross-language boundary is the one a type cannot
 * police, so it gets a gate.
 *
 * β›” ONE CLIENT LIST, IMPORTED β€” never re-declared. `aggregations.ts` and the field editor import
 * from here rather than keeping their own copy, which is why this lives in the pure data module
 * beside `TYPE_LABELS` and `CREATABLE_MODES`: a second client list would need a second gate, and
 * the two would drift in the direction nobody is watching. C7 says "C publishes, E mirrors"; a
 * mirror that is an import cannot fall out of step at all.
 *
 * β›” NOT the chart vocabulary. `CHART_AGGS` (`aios_grid.py`, `viz/chartData.ts`) spells it `avg`
 * and gatekeeps a STORED value β€” renaming it would silently turn saved charts into sums. This
 * list spells it `average`, matching `ROLLUP_FNS` (16 names, live in production), so a column
 * summary and a rollup fold say the same word for the same operation.
 *
 * ⚠ `median` is net-new β€” in neither `CHART_AGGS` nor `ROLLUP_FNS`.
 * ⚠ `count` counts ROWS in the scope, not non-blank cells.
 * ⚠ Which types may carry which: `sum/average/median/min/max` are numeric-only and the evaluator
 * for that is ALREADY `isNumericFieldType` (types.ts) β€” do not write a second one. `count` is
 * legal on any type.
 */
// ⭐ W29-T74 β€” an ALIAS of `types.AggName`, not a fifth copy of the union. `Field.agg` is typed
// `AggName`, so a second literal here would be a type that has to be kept in step by eye with a
// type the compiler already owns β€” the same defect as the array below, one level up.
export type FieldAgg = AggName;

/** ORDERED β€” the order is the picker's order, on both engines. */
export const FIELD_AGGS: readonly FieldAgg[] = [
  "sum",
  "average",
  "median",
  "min",
  "max",
  "count",
];

/**
 * Human labels, in the summary bar's own compact register (Airtable's wording).
 *
 * ⚠ `FIELD_AGG_LABELS`, not `AGG_LABELS`, and the prefix is load-bearing: `viewModes.tsx` already
 * has a module-local `AGG_LABELS` for the CALENDAR summary picker over `CHART_AGGS`, where the
 * same five names wear different words ("Total", "Lowest", "Highest") for a day cell. Two tables
 * called `AGG_LABELS` describing two vocabularies is how a future import lands on the wrong one.
 */
export const FIELD_AGG_LABELS: Record<FieldAgg, string> = {
  sum: "Sum",
  average: "Average",
  median: "Median",
  min: "Min",
  max: "Max",
  count: "Count",
};


// ------------------------------------------------------------ glide sprites

/** Serialize one shape to SVG source in an explicit colour (canvas sprites get
 *  no `currentColor` β€” glide hands the painter the theme colours directly). */
function shapeSource(s: IconShape, color: string): string {
  return s.fill
    ? `<path d="${s.d}" fill="${color}"/>`
    : `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
        `stroke-linecap="round" stroke-linejoin="round"/>`;
}

function sprite(shapes: IconShape[]) {
  return ({ fgColor }: { fgColor: string }) =>
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
    shapes.map((s) => shapeSource(s, fgColor)).join("") +
    `</svg>`;
}

/** Glide header-icon NAME for a field type β€” the `icon` a GridColumn asks for. */
export function typeIconName(type: FieldType): string {
  return `t_${type}`;
}

/**
 * The sprite map handed to <DataEditor headerIcons>. One entry per field type
 * (I20 draws the type mark in every column header), built from the same shapes
 * the React icons use.
 *
 * Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
 * why theme.ts must set it β€” the library default is #FFFFFF, i.e. invisible on
 * our header (that was I21's actual bug, not a too-pale hex of ours).
 */
export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
  Object.fromEntries(
    (Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
  );

/**
 * The header sprite map handed to <DataEditor headerIcons>. Two families:
 *
 *   t_<type>   wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
 *              from the same shapes the React icons use. Painted by glide in
 *              `theme.fgIconHeader`.
 *   aiosInfo   wave-5 item 6, restyled by wave-9 I3 - the description (i).
 *              OUTLINE ONLY: a dark-grey ring with a transparent interior, per
 *              the owner. It still deliberately IGNORES the colours glide hands
 *              it, for the reason wave-8 recorded - glide's "special" variant is
 *              accentColor behind bgHeader, which under the C1 pastels is a pale
 *              glyph on a pale disc, i.e. I21 in a new costume.
 *              ⚠ It is NO LONGER a column `overlayIcon`. Glide draws an overlay
 *              at a hard-coded offset from the TYPE mark on the far LEFT of the
 *              header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
 *              aligned. It is now painted by CustomerGrid's `drawHeader`
 *              callback at `infoMarkRect()` - see overlayPlacement.ts.
 */
export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
  ...TYPE_SPRITES,
  aiosInfo: () =>
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
    `<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
    `<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
    `stroke-linecap="round"/>` +
    `<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
    `</svg>`,
};