File size: 37,306 Bytes
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665e5ea
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665e5ea
 
 
 
 
20bf676
 
 
 
 
665e5ea
 
20bf676
 
 
 
 
 
665e5ea
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665e5ea
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// ---------------------------------------------------------------------------
// customer-grid / cells.ts
// The ONLY place that turns a (field, value) into a glide GridCell. glide has
// no per-column formatter β€” all formatting is per-cell here, in getCellContent.
//
//  currency/int -> NumberCell (raw number kept for copy/paste, right-aligned)
//  pct          -> NumberCell showing v.toFixed(1)+"%" (yoy_pct is already in percent-points)
//  date         -> TextCell   (locale date string; read-only, no picker in core)
//  status       -> BubbleCell (Airtable-style colored pill via themeOverride)
//  checkbox     -> BooleanCell (wave-5 item 11; overlay stores '1' or '')
//  url          -> UriCell    (renders as a link; opening is CustomerGrid's click path)
//  phone/email  -> TextCell   (actionable links in the record drawer)
//  rating       -> Custom     (canvas-drawn stars β€” SVG/vector paths, NEVER emoji)
//  created_time -> TextCell   (read-only; the row's `_created`, injected at its key)
//  formula      -> NumberCell (client-computed, read-only; blank = could not compute)
//  text/other   -> TextCell
//
// Wave-5 item 10: `field.format` shapes the DISPLAY string (thousands /
// decimals 0..4 / 34.0M abbreviation for numbers; include-time + local|utc
// for dates). A field with NO format renders byte-identically to before the
// format existed β€” parity is asserted by keeping the default paths in terms
// of the same toLocaleString calls.
//
// Editability is the CALLER's decision (permissions + stratum + type); this
// module just applies the flag it is handed.
// ---------------------------------------------------------------------------

import { GridCellKind } from "@glideapps/glide-data-grid";
import type {
  CustomCell,
  CustomRenderer,
  GridCell,
  Theme,
} from "@glideapps/glide-data-grid";
import { assetUrl } from "./catalogData";
import type { Field } from "./types";
import { ratingMax } from "./types";
import {
  LP_BLUE_TEXT,
  LP_BLUE_TINT,
  LP_GREEN_DEEP,
  LP_GREEN_TINT,
  LP_RED_DEEP,
  LP_RED_TINT,
  LP_YELLOW_DEEP,
  LP_YELLOW_TINT,
  STATUS_BUBBLE,
} from "./theme";
// ⚠ ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
// another. C-AVATAR names choiceColors' as the avatar fallback's source, so the two stay
// distinguishable at the call site rather than one silently shadowing the other.
import { optionTint, pickTint as choicePickTint } from "./choiceColors";
import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
  codePreview, formulaIsText, jsonPreview, num, numberText, numericIsBlank,
  userCellPayload } from "./display";
import type { AutomationState, CellValue, UserCellData } from "./display";

// Wave-7 (item W2): the pure display-string half moved to display.ts so the
// export builders can run under node without dragging glide along. Re-exported
// here so every existing import keeps working unchanged.
export { checkboxOn, dateTimeText, formatDisplay } from "./display";
// C-AVATAR β€” the pure halves live in display.ts (node-reachable, so a gate can hold them);
// re-exported here so callers keep importing "the cell module" for everything avatar-shaped.
export { avatarInitials, avatarSize, userCellPayload } from "./display";
export type { UserCellData } from "./display";
// C5-AUTOFIELD β€” same arrangement: the string parsing is pure and lives in display.ts (a node
// gate can hold it); only the canvas tint table below needs this module.
export { automationDetail, automationState, automationStateLabel } from "./display";
export type { AutomationState } from "./display";
// Wave-23 C7 β€” same arrangement again: the parse, the preview and the pretty-printer are pure
// (`verify_grid_ux` drives them under node); only the canvas cell below needs this module.
export { codePreview, jsonParse, jsonPretty, jsonPreview, MAX_JSON_BYTES } from "./display";

/**

 * Wave-18 C5-AUTOFIELD β€” the cell tint per automation state.

 *

 * Standing brand rule: the PASTEL is the fill and the measured `-deep` variant carries the ink.

 * `bgCell` + `textDark` is that pairing at cell scale, and every combination below is one of the

 * pairs already measured for `STATUS_BUBBLE` (β‰₯ 4.9:1), so nothing new needed measuring.

 *

 * `none` deliberately has NO override: a cell that has never run reads as an ordinary empty

 * cell, because a tint would claim the automation had produced something.

 */
const AUTOMATION_TINT: Record<AutomationState, Partial<Theme> | undefined> = {
  ok: { bgCell: LP_GREEN_TINT, textDark: LP_GREEN_DEEP },
  partial: { bgCell: LP_YELLOW_TINT, textDark: LP_YELLOW_DEEP },
  blocked: { bgCell: LP_YELLOW_TINT, textDark: LP_YELLOW_DEEP },
  error: { bgCell: LP_RED_TINT, textDark: LP_RED_DEEP },
  queued: { bgCell: LP_BLUE_TINT, textDark: LP_BLUE_TEXT },
  none: undefined,
};

/** Stable pill tint for a user-defined choice. Hashed from the VALUE, so the same choice keeps

 *  its colour across rows, sessions and users without anyone picking one β€” and a renamed option

 *  simply gets a new colour rather than inheriting a stale mapping. */
const PICK_TINTS = [
  // 2026-07-31 (owner item 7): ONE construction β€” every pill is a `-tint` wash carrying its
  // family's `-deep` ink, the standing brand rule ("pastels are fills; text takes the deep
  // weight"). The old second pass (full pastel + near-black ink) is GONE: it is what read as
  // "black font on a saturated chip". Six families now β€” the four C1 hues, the brand purple,
  // and a neutral grey β€” all mirrored from index.css tokens; every pairing measures β‰₯ 5.0:1.
  { bg: "#EDF3FD", fg: "#4F6079" },   // blue-tint  / LP_BLUE_TEXT
  { bg: "#EBF6EF", fg: "#35754E" },   // green-tint / green-deep
  { bg: "#FBF4E0", fg: "#7E6428" },   // yellow-tint/ yellow-deep
  { bg: "#FCEEEC", fg: "#A3453C" },   // red-tint   / red-deep
  { bg: "#F1EEFB", fg: "#6B57A8" },   // purple-tint/ purple-deep (--lp-purple family)
  { bg: "#EEF1F4", fg: "#4B5563" },   // neutral wash / slate β€” the sixth distinct family
];

export function pickTint(v: string): { bg: string; fg: string } {
  let h = 0;
  for (let i = 0; i < v.length; i += 1) h = (h * 31 + v.charCodeAt(i)) >>> 0;
  return PICK_TINTS[h % PICK_TINTS.length];
}

// --- rating: a canvas-drawn star row (owner constant: vector, never emoji) --

export interface RatingCellData {
  kind: "aios-rating";
  /** 0 = unset (draws all-empty outlines). */
  value: number;
  max: number;
}
export type RatingCell = CustomCell<RatingCellData>;

function drawStar(

  ctx: CanvasRenderingContext2D,

  cx: number,

  cy: number,

  r: number,

  filled: boolean

): void {
  ctx.beginPath();
  for (let i = 0; i < 10; i += 1) {
    const rad = (Math.PI / 5) * i - Math.PI / 2;
    const rr = i % 2 === 0 ? r : r * 0.45;
    const px = cx + Math.cos(rad) * rr;
    const py = cy + Math.sin(rad) * rr;
    if (i === 0) ctx.moveTo(px, py);
    else ctx.lineTo(px, py);
  }
  ctx.closePath();
  if (filled) {
    // I7c: C1's yellow is a FILL token (1.38:1) and a star has to be seen, so the
    // filled star rides the deep amber variant: 3.29:1 on white, clearing the 3:1
    // non-text bar the old gold #C8A24B never did (it measured 2.41:1).
    ctx.fillStyle = "#A98A3E";
    ctx.fill();
  } else {
    ctx.strokeStyle = "rgba(118, 143, 182, 0.55)"; // blue-deep outline, empty slots
    ctx.lineWidth = 1;
    ctx.stroke();
  }
}

// --- user: the assignee AVATAR (wave-14 item 11 / R6 / contract C-AVATAR) -----
//
// R6: a grid cell shows the PHOTO OR ICON ONLY β€” no name text. (Pickers and the record modal
// are where a name belongs; the column is a glanceable "who owns this", and thirty repetitions
// of "Farhan Sanyoto" down a column is the thing the owner asked to stop reading.)
//
// The name is still the cell's COPY value. An avatar-only cell with no `copyData` makes an
// assignee column copy as nothing β€” a real regression that no screenshot shows.

export type UserCell = CustomCell<UserCellData>;

/**

 * Decoded avatar images, keyed by their data URL. `null` = this URL failed to decode, so we

 * stop retrying it and paint the fallback forever (a corrupt stored photo must not spin).

 *

 * ⚠ Module scope, like the tint tables above: an Image per cell per frame would re-decode a

 * base64 payload on every scroll tick.

 */
const _avatarImgs = new Map<string, HTMLImageElement | null>();
let _avatarRepaint: (() => void) | undefined;

/**

 * ⚠ THE HALF THAT IS EASY TO FORGET. glide repaints when `getCellContent`'s identity changes β€”

 * it knows nothing about an `Image.onload` that fires three frames later. Without this hook the

 * fallback initials paint once and STAY, and because the fallback is a deliberate, correct-looking

 * design, nothing on screen says the photo never arrived. (Same shape as wave-13's "honest empty

 * state that made the failure look deliberate".)

 *

 * CustomerGrid calls this once and bumps a counter that is in `useGetCellContent`'s deps.

 */
export function setAvatarRepaint(fn: (() => void) | undefined): void {
  _avatarRepaint = fn;
}

function avatarImage(dataUrl: string): HTMLImageElement | null {
  const hit = _avatarImgs.get(dataUrl);
  if (hit !== undefined) return hit;
  // A DOM-less environment (a node gate, a locked-down embed) has no Image constructor. Cache
  // the refusal so the fallback is what paints, rather than throwing inside a draw call.
  if (typeof Image === "undefined") {
    _avatarImgs.set(dataUrl, null);
    return null;
  }
  const img = new Image();
  _avatarImgs.set(dataUrl, img);
  img.onload = () => _avatarRepaint?.();
  img.onerror = () => {
    _avatarImgs.set(dataUrl, null);
    _avatarRepaint?.();
  };
  img.src = dataUrl;
  return img;
}

/** C-AVATAR β€” the circle, photo or not. Exported so any other canvas surface paints the same

 *  avatar rather than growing a second almost-matching one. */
export function drawAvatar(

  ctx: CanvasRenderingContext2D,

  cx: number,

  cy: number,

  r: number,

  name: string,

  photo: string | undefined,

  fontFamily: string

): void {
  const img = photo ? avatarImage(photo) : null;
  // `complete` alone is not enough: a FAILED decode is also "complete", with a zero natural size.
  if (img && img.complete && img.naturalWidth > 0) {
    ctx.save();
    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, Math.PI * 2);
    ctx.clip();
    // COVER, not contain: a portrait must fill the circle, not sit letterboxed inside it.
    const scale = Math.max((r * 2) / img.naturalWidth, (r * 2) / img.naturalHeight);
    const w = img.naturalWidth * scale;
    const h = img.naturalHeight * scale;
    ctx.drawImage(img, cx - w / 2, cy - h / 2, w, h);
    ctx.restore();
    return;
  }
  // The fallback, and it is the DEFAULT state until HOST's C-AVATAR lands β€” never a broken-image
  // glyph. Hashed from the full name so one person keeps one colour across rows and sessions.
  const tint = choicePickTint(name);
  ctx.save();
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.fillStyle = tint.bg;
  ctx.fill();
  ctx.fillStyle = tint.fg;
  ctx.font = `600 ${Math.round(r * 0.9)}px ${fontFamily}`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(avatarInitials(name), cx, cy + 0.5);
  ctx.restore();
}

/** Registered once on the DataEditor (customRenderers), beside the rating renderer. */
export const userCellRenderer: CustomRenderer<UserCell> = {
  kind: GridCellKind.Custom,
  isMatch: (cell): cell is UserCell =>
    (cell.data as UserCellData | undefined)?.kind === "aios-user",
  draw: (args, cell) => {
    const { ctx, rect, theme } = args;
    const { name, photo } = cell.data;
    if (!name) return true; // unassigned paints nothing β€” an empty circle would look assigned
    const d = avatarSize(rect.height);
    const cx = rect.x + theme.cellHorizontalPadding + d / 2;
    const cy = rect.y + rect.height / 2;
    // Never paint outside the cell: a column dragged narrow drops the avatar rather than
    // bleeding it over its neighbour.
    if (cx + d / 2 > rect.x + rect.width - theme.cellHorizontalPadding) return true;
    drawAvatar(ctx, cx, cy, d / 2, name, photo, theme.fontFamily);
    return true;
  },
};

// --- image: the record THUMBNAIL (wave-19 R7 / contract C5) -------------------
//
// The cell holds a REFERENCE, never bytes (`types.ts: imageRefKind`), so what paints here is an
// <img> the browser fetches from the asset routes and caches like any other image. Same
// module-scoped decode cache and same repaint hook as the avatar above, and for the same reason:
// glide repaints on `getCellContent` identity, and knows nothing about an `onload` three frames
// later. Without the hook the placeholder paints once and stays β€” a deliberate-looking empty
// frame over a picture that did arrive.

export interface ImageCellData {
  kind: "aios-image";
  /** The resolved URL, or "" when the cell is empty (paints the placeholder frame). */
  url: string;
  /** The raw cell value β€” the copy/export payload, so a picture column is not invisible in a CSV. */
  ref: string;
}
export type ImageCell = CustomCell<ImageCellData>;

const _cellImgs = new Map<string, HTMLImageElement | null>();

function cellImage(url: string): HTMLImageElement | null {
  const hit = _cellImgs.get(url);
  if (hit !== undefined) return hit;
  if (typeof Image === "undefined") {
    _cellImgs.set(url, null);
    return null;
  }
  const img = new Image();
  _cellImgs.set(url, img);
  img.onload = () => _avatarRepaint?.();
  img.onerror = () => {
    // A reference that does not resolve is NOT retried: an unknown SKU code is the ordinary
    // state of a product with no master on file, and retrying it every repaint would be a
    // 404 per scroll tick per row.
    _cellImgs.set(url, null);
    _avatarRepaint?.();
  };
  img.src = url;
  return img;
}

/** Registered once on the DataEditor (customRenderers), beside rating and user. */
export const imageCellRenderer: CustomRenderer<ImageCell> = {
  kind: GridCellKind.Custom,
  isMatch: (cell): cell is ImageCell =>
    (cell.data as ImageCellData | undefined)?.kind === "aios-image",
  draw: (args, cell) => {
    const { ctx, rect, theme } = args;
    const { url } = cell.data;
    const pad = theme.cellHorizontalPadding;
    // Square, inset by 3px top and bottom so a tall row shows a bigger picture and a short one
    // still leaves the grid line visible.
    const size = Math.max(0, Math.min(rect.height - 6, rect.width - pad * 2));
    if (size <= 2) return true;              // column dragged too narrow: paint nothing, never bleed
    const x = rect.x + pad;
    const y = rect.y + (rect.height - size) / 2;
    const img = url ? cellImage(url) : null;
    ctx.save();
    ctx.beginPath();
    // A 3px radius, matching the choice pills β€” one rounding vocabulary across the canvas.
    const r = Math.min(3, size / 2);
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + size, y, x + size, y + size, r);
    ctx.arcTo(x + size, y + size, x, y + size, r);
    ctx.arcTo(x, y + size, x, y, r);
    ctx.arcTo(x, y, x + size, y, r);
    ctx.closePath();
    // `complete` alone is not enough β€” a FAILED decode is also complete, with a zero natural size.
    if (img && img.complete && img.naturalWidth > 0) {
      ctx.clip();
      // CONTAIN, not cover: a product photo cropped to a square loses the thing being sold.
      // Letterboxing inside the frame is the honest fit for a catalogue picture.
      const scale = Math.min(size / img.naturalWidth, size / img.naturalHeight);
      const w = img.naturalWidth * scale;
      const h = img.naturalHeight * scale;
      ctx.drawImage(img, x + (size - w) / 2, y + (size - h) / 2, w, h);
    } else {
      // The empty frame: a quiet outline, never a broken-image glyph and never a coloured block
      // that would read as content. Identical whether the cell is empty or the fetch failed β€”
      // the record modal is where a user finds out which, in words.
      ctx.fillStyle = LP_BLUE_TINT;
      ctx.fill();
      ctx.strokeStyle = "rgba(118, 143, 182, 0.45)";
      ctx.lineWidth = 1;
      ctx.stroke();
    }
    ctx.restore();
    return true;
  },
};

/** Registered once on the DataEditor (customRenderers). Pure canvas paths. */
export const ratingCellRenderer: CustomRenderer<RatingCell> = {
  kind: GridCellKind.Custom,
  isMatch: (cell): cell is RatingCell =>
    (cell.data as RatingCellData | undefined)?.kind === "aios-rating",
  draw: (args, cell) => {
    const { ctx, rect, theme } = args;
    const { value, max } = cell.data;
    const size = 13;
    const gap = 3;
    const cy = rect.y + rect.height / 2;
    let cx = rect.x + theme.cellHorizontalPadding + size / 2;
    const maxRight = rect.x + rect.width - theme.cellHorizontalPadding;
    for (let i = 0; i < max; i += 1) {
      if (cx + size / 2 > maxRight) break; // never paint outside the cell
      drawStar(ctx, cx, cy, size / 2, i < value);
      cx += size + gap;
    }
    return true;
  },
};

/** Build the cell for one (field, value). `editable` gates overlay + readonly.

 *

 * ⚠ A BLANK numeric cell renders EMPTY, never "$0" (2026-07-27). `''`/null is how a value that

 * could not be computed degrades β€” a measure column whose store query failed, an overlay number

 * nobody typed, a FORMULA that hit an error β€” and painting it as $0 would state a number nobody

 * computed. A real 0 arrives as the NUMBER 0 (the pool and the zero-group both emit it) and

 * still renders "$0". Mirrors the blank-vs-zero rule the engine already has (`isBlank`: 0 is

 * NOT blank). */
export function makeCell(

  field: Field,

  v: CellValue,

  editable: boolean,

  /** C-AVATAR β€” username β†’ data URL, straight off `GridWorkspace.userAvatars`. Absent, or a

   *  username absent from it, paints the initials fallback. */

  userAvatars?: Record<string, string>

): GridCell {
  // β›” NO MACHINE WASH (owner item 2, 2026-08-06): *"Remove the light grey highlight for the
  // column that is supposedly pre-set."* Wave-23 R9 introduced a grey background on every
  // machine-owned cell; on a `ut_*` database that is EVERY column but one, so the grid read as a
  // sea of grey with a white stripe rather than as a table. The fact it was trying to state β€”
  // *something else fills this column* β€” is now stated in WORDS where a reader will meet it
  // (the `Pre-set` chip in Hide fields, `isPresetField`) and enforced where it matters (the
  // cells refuse the edit, `isMachineWritten`). A label beats a tint the reader has to be taught.
  return baseCell(field, v, editable, userAvatars);
}

/* β›” THE MACHINE WASH IS RETIRED (owner item 2, 2026-08-06).



   Wave-23 C8/R9 washed every machine-owned cell grey. On the Customer grid that was a handful

   of columns; on a `ut_*` database it is EVERY column but the identity one, so the owner's

   Instagram table rendered as a grey sheet with one white stripe. `withMachineWash` and

   `theme.machineCellTheme` are deleted with it β€” a composer nothing composes is a subject a gate

   can still go green on ([[gate-answers-the-wrong-question]]), so the precedence assertions that

   guarded it were RETARGETED onto the new law rather than dropped: `makeCell` adds no wash, and

   a machine cell keeps exactly the override it already carried.



   What replaced the signal, because removing it without replacing it would be a loss:

     Β· the `Pre-set` chip in Hide fields (`isPresetField`) β€” the same word Odoo columns use;

     Β· the custom-field DOT no longer painted on them (they are not yours to edit);

     Β· and the cells genuinely refuse the edit now (`isMachineWritten`), which the wash never did. */

function baseCell(

  field: Field,

  v: CellValue,

  editable: boolean,

  userAvatars?: Record<string, string>

): GridCell {
  const ro = { allowOverlay: editable, readonly: !editable };
  const blank = v == null || v === "";
  // β›” W29-T81 β€” A NUMERIC COLUMN HOLDING A NON-NUMBER PAINTS NOTHING, not `0`. `blank` above is
  // emptiness; this is emptiness OR a value no reader would call a number. The import door can
  // now put a spreadsheet's "seventeen-ish" in an `int` column, and `num()` answers 0 for it β€”
  // a fabricated figure on the canvas beside an honest em-dash in the record panel.
  const noNumber = numericIsBlank(v);

  switch (field.type) {
    case "currency":
      return {
        kind: GridCellKind.Number,
        data: noNumber ? undefined : num(v),
        displayData: noNumber ? "" : "$" + numberText(num(v), field.format),
        contentAlign: "right",
        ...ro,
      };
    case "int":
      return {
        kind: GridCellKind.Number,
        data: noNumber ? undefined : num(v),
        displayData: noNumber ? "" : numberText(num(v), field.format),
        contentAlign: "right",
        ...ro,
      };
    case "formula": {
      // Computed client-side (formulaEngine.ts) over values ALREADY injected at this key by
      // CustomerGrid's computedRows. Read-only by nature β€” the caller passes editable=false.
      // 2026-07-31 (owner item 2): a formula may now return TEXT (CONCATENATE, &, TEXT(),
      // TRUE/FALSE) β€” a non-numeric result renders as a text cell, never as NaN.
      //
      // β›” THE TEST WAS `!Number.isFinite(num(v))` AND IT NEVER FIRED. `num()` returns 0 for
      // anything non-finite, so that read `Number.isFinite(0)` β€” always true β€” and every text
      // formula printed as `0` here and in `formatDisplay`, which held its own copy of the same
      // broken test. `formulaIsText` is now the ONE test, asked of the RAW value, shared by both
      // renderers precisely because two copies is how they drifted. See its note in display.ts.
      // ⚠ `formulaIsBlank`, NOT this function's shared `blank` (which is `v === ""`). A formula
      // returning a SPACE is neither empty by that test nor text by the one below it, so it fell
      // through to the numeric path and painted `0` β€” `Number(" ")` is 0. The two predicates are
      // written to be total over a formula's three states; using only one of them re-opens the
      // gap in miniature.
      const formulaBlank = formulaIsBlank(v);
      if (!formulaBlank && formulaIsText(v)) {
        return {
          kind: GridCellKind.Text,
          data: v,
          displayData: v,
          allowOverlay: false,
          readonly: true,
        };
      }
      const asNum = num(v);
      return {
        kind: GridCellKind.Number,
        data: formulaBlank ? undefined : asNum,
        displayData: formulaBlank ? "" : numberText(asNum, field.format),
        contentAlign: "right",
        allowOverlay: false,
        readonly: true,
      };
    }
    case "pct":
      return {
        kind: GridCellKind.Number,
        data: noNumber ? undefined : num(v),
        displayData: noNumber ? "" : num(v).toFixed(1) + "%",
        contentAlign: "right",
        ...ro,
      };
    /* ⭐ WAVE-26 ITEM 3 β€” `copyData` IS THE STORED STAMP, and it is not decoration.

       MEASURED in the installed glide (`data-editor/copy-paste.js::convertCellToBuffer`): a

       `Text` cell copies as `copyData ?? displayData`, and the text/plain buffer takes that

       FORMATTED string β€” so without this line, copying a date puts the DISPLAY string on the

       clipboard. That was survivable while the display read `8/5/2026` (no space, so

       `new Date()` re-parsed it); item 3 changed it to `Aug 5, 2026`, whose first space

       `parseStamp` turns into `AugT5, 2026` β€” an Invalid Date that renders verbatim forever.

       β›” So a display change silently became a WRITE change, one paste away. The same argument

       `userCellPayload` records for the assignee cell: a value's clipboard identity is its

       STORED form, and leaving it to a fallback makes it depend on which buffer the browser

       hands back (the text/html one carries `gdg-raw-value`, the plain one does not).

       ⚠ `coerceClipboardValue` normalises a pasted display string as the second half of this;

       neither is sufficient alone β€” this one fixes OUR copy, that one fixes Excel's. */
    case "date":
      return {
        kind: GridCellKind.Text,
        data: String(v ?? ""),
        displayData: dateTimeText(field, v),
        copyData: String(v ?? ""),
        ...ro,
      };
    case "created_time":
      return {
        kind: GridCellKind.Text,
        data: String(v ?? ""),
        displayData: dateTimeText(field, v),
        copyData: String(v ?? ""),
        allowOverlay: false,
        readonly: true,
      };
    case "checkbox":
      // glide toggles a non-readonly BooleanCell on click and reports it through
      // onCellEdited β€” no overlay editor involved.
      return {
        kind: GridCellKind.Boolean,
        data: checkboxOn(v),
        allowOverlay: false,
        readonly: !editable,
      };
    case "url":
      // Renders as a link. OPENING is CustomerGrid's click path (scheme-guarded);
      // hoverEffect gives the pointer affordance.
      return {
        kind: GridCellKind.Uri,
        data: String(v ?? ""),
        hoverEffect: true,
        ...ro,
      };
    case "image": {
      // Wave-19 R7 / C5. `allowOverlay:false` for the same reason `select` has it: the value is
      // a REFERENCE that is picked or uploaded, never typed into a cell β€” the record modal owns
      // the picker and the upload. `copyData` carries the raw ref so the column is not invisible
      // in a copy or an export (the lesson the avatar cell booked one wave earlier).
      const ref = String(v ?? "").trim();
      return {
        kind: GridCellKind.Custom,
        data: {
          kind: "aios-image",
          url: ref ? assetUrl(ref, "web") : "",
          ref,
        } satisfies ImageCellData,
        copyData: ref,
        allowOverlay: false,
      };
    }
    case "json": {
      // ⭐ Wave-23 C7 (owner item 5) β€” the compact preview; the DOCUMENT lives in the viewer.
      //
      // `allowOverlay: false` for the same reason `select` and `image` carry it: glide's text
      // overlay is a one-line box, and a one-line box over a 32 KB document is an editor that
      // can only damage the value β€” one keystroke in the wrong place and a well-formed payload
      // becomes unparseable, saved. CustomerGrid opens the viewer on click instead (the same
      // `onCellClicked` path the pickers use), and THAT is where the raw text is editable, with
      // parse-on-save.
      //
      // ⚠ `readonly` is NOT set, and the difference matters: `allowOverlay:false` means "no
      // inline editor", while `readonly` would tell glide the CELL cannot change β€” which would
      // also block the paste path that legitimately writes a whole document into it.
      //
      // ⚠ `copyData` carries the RAW document, never the preview (the lesson the image cell
      // booked in wave 19). Copy a json column and you get the payload; copy the preview and
      // you get the sentence "{…} 5 keys", which is not data and cannot be pasted back.
      const raw = String(v ?? "");
      const text = jsonPreview(raw);
      return {
        kind: GridCellKind.Text,
        data: text,
        displayData: text,
        copyData: raw,
        allowOverlay: false,
      };
    }
    case "code": {
      // ⭐ Wave-27 item 13 (R13) β€” the compact preview; the SNIPPET lives in the editor.
      //
      // Every rule the json cell above states applies here for the same reasons, so this is
      // written the same way rather than differently: `allowOverlay:false` because glide's
      // one-line text overlay over a multi-line snippet is an editor that can only damage the
      // value; `readonly` deliberately NOT set, so the paste path that legitimately writes a
      // whole snippet still works; `copyData` the RAW text, never the preview, or copying a code
      // column would yield "SELECT * FROM …  +12 more", which is not data and cannot be pasted
      // back (the lesson the image cell booked in wave 19, and the W26 date-cell repeat of it).
      const raw = String(v ?? "");
      const text = codePreview(raw);
      return {
        kind: GridCellKind.Text,
        data: text,
        displayData: text,
        copyData: raw,
        allowOverlay: false,
      };
    }
    case "rating": {
      const max = ratingMax(field);
      const n = Math.max(0, Math.min(max, Math.round(num(v))));
      return {
        kind: GridCellKind.Custom,
        data: { kind: "aios-rating", value: blank ? 0 : n, max } satisfies RatingCellData,
        copyData: blank ? "" : String(n),
        allowOverlay: false,
      };
    }
    case "automation": {
      // Wave-18 C5-AUTOFIELD. The cell is what the last RUN wrote β€” `ok Β· 2026-08-03 14:10 Β·
      // 12 posts` β€” so it is read-only by NATURE, not by policy: a value typed here would be
      // overwritten by the next run with nothing anywhere saying so. The column's behaviour is
      // configured through its gear, the way a formula's expression is.
      //
      // A tinted CELL rather than a bubble, and that is the whole design: the state and the
      // detail are one sentence, and splitting them into two pills would put "2026-08-03 14:10
      // Β· 12 posts" in a bubble, which is not what a bubble is for. `bgCell` is the one theme
      // key glide alpha-blends (theme.ts:172), so the tints below are the flat pastels rather
      // than anything semi-transparent.
      const text = String(v ?? "");
      return {
        kind: GridCellKind.Text,
        data: text,
        displayData: text,
        allowOverlay: false,
        readonly: true,
        themeOverride: AUTOMATION_TINT[automationState(text)],
      };
    }
    case "link": {
      // ⭐ 2026-08-07 β€” a relation, painted as ONE bubble saying how many rows it reaches.
      //
      // β›” NOT one bubble per linked row, and that is a measured decision rather than a
      // simplification: the cell holds row IDS, and a column of `1,2,3` pills tells a reader
      // nothing β€” the ids are not names. Airtable can paint the linked record's PRIMARY value
      // because it has that row loaded; this client has not loaded the other table at all. So
      // the honest cell is the COUNT, and the record drawer is where the rows themselves belong.
      //
      // ⚠ `copyData` carries the RAW id list, never the "12 posts" sentence β€” the W26 date-cell
      // lesson, where a glide Text cell copies `copyData ?? displayData` and the FORMATTED
      // string silently reached the clipboard and then the paste path. A count is not data and
      // cannot be pasted back into a relation.
      const ids = String(v ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
      return {
        kind: GridCellKind.Bubble,
        data: ids.length ? [ids.length === 1 ? "1 record" : `${ids.length} records`] : [],
        copyData: String(v ?? ""),
        allowOverlay: false,
      };
    }
    case "rollup": {
      // ⭐ 2026-08-07 β€” an aggregate the HOST computed. Read-only by nature, exactly like
      // `formula`: the value is arithmetic, and a value box over arithmetic is an editor that
      // can only produce a number the next refresh throws away.
      //
      // ⚠ BLANK STAYS BLANK. `_rollup_fold` returns "" for "no rows to aggregate" and only the
      // count family ever returns a real 0 β€” so painting `0` here for an empty cell would invent
      // the measurement the server just refused to invent. `num(v)` would do exactly that
      // (`num("")` is 0), which is why the raw string is rendered rather than a parsed number.
      // ⭐ 2026-08-10 (owner: *"I want to be able to use commas for numbers so instead of 1000
      // its 1,000"*) β€” A ROLLUP IS FORMATTED LIKE ANY OTHER NUMBER, and it was the only numeric
      // kind that was not. `int` and `currency` have gone through `numberText` since wave 5 and
      // `formula` since it became a real column; a rollup rendered its raw fold string, so an
      // "Avg views" column read `1491552.43` while the `Followers` column beside it read
      // `56,147,007`. Two numeric columns, two dialects, one grid.
      //
      // ⚠ THE BLANK RULE IS PRESERVED EXACTLY, and it is why this is a guarded branch rather
      // than a call. `_rollup_fold` returns "" for "no rows to aggregate" and only the count
      // family ever returns a real 0, so `num(v)` β€” which maps "" to 0 β€” would paint the
      // measurement the server just refused to invent. A blank stays the empty string, and a
      // fold that is not a number at all (`concatenate`, `arrayunique`, `latest` over text)
      // keeps its own text.
      const raw = String(v ?? "");
      const asNum = raw.trim() === "" ? NaN : Number(raw);
      const text = Number.isFinite(asNum) ? numberText(asNum, field.format) : raw;
      return {
        kind: GridCellKind.Text,
        data: text,
        displayData: text,
        // β›” THE CLIPBOARD TAKES THE RAW NUMBER, NOT THE FORMATTED ONE β€” wave 26's measured
        // defect on `date`, where a glide Text cell copies `copyData ?? displayData` and the
        // formatted string reached the clipboard, so pasting `Aug 5, 2026` into a number column
        // stored the words. `1,491,552.43` pastes as text everywhere; `1491552.43` pastes as
        // a number.
        copyData: raw,
        allowOverlay: false,
        readonly: true,
      };
    }
    case "status":
      return {
        kind: GridCellKind.Bubble,
        data: [String(v ?? "")],
        allowOverlay: false,
        themeOverride: STATUS_BUBBLE[String(v ?? "").toLowerCase()],
      };
    case "user": {
      // Wave-14 item 11 / R6 β€” the AVATAR ONLY, no name text. Split out of the `select` branch
      // below (it was a name pill until this wave) because "who owns this" is a face, not a
      // sentence, once a column has thirty rows of it.
      //
      // Still `allowOverlay:false`, for the same reason `select` is: the value is PICKED, never
      // typed, and CustomerGrid opens its anchored picker on click (onCellClicked β†’ isPickType,
      // which already includes `user`, so the click path needs no change).
      //
      // ⚠ `copyData` carries the NAME. The cell shows no text, so without this the column would
      // copy and export as empty β€” invisible in every screenshot. Built by `userCellPayload` in
      // display.ts so a node gate can actually assert that (this module imports glide).
      return {
        kind: GridCellKind.Custom,
        ...userCellPayload(String(v ?? ""), userAvatars),
        allowOverlay: false,
      };
    }
    case "select": {
      // Picked, never typed. `allowOverlay:false` keeps glide's text editor OUT of the way β€”
      // a free-text editor on a constrained field is how a column ends up holding "Done",
      // "done" and "DONE" as three different values. CustomerGrid opens an anchored picker on
      // click instead (onCellClicked), which is also why this stays dependency-free: glide's
      // dropdown cell lives in a separate package we deliberately have not added.
      const s = String(v ?? "");
      const tint = optionTint(field, s);
      return {
        kind: GridCellKind.Bubble,
        data: s ? [s] : [],
        allowOverlay: false,
        themeOverride: tint ? { bgBubble: tint.bg, textBubble: tint.fg } : undefined,
      };
    }
    case "multiselect": {
      // The cell holds a comma-joined SET (the `multi` contract the Cohorts column uses), so
      // every member paints as its own pill. One themeOverride per CELL is all glide offers, so
      // the pills share the first member's tint rather than each carrying its own.
      const parts = String(v ?? "")
        .split(",")
        .map((s) => s.trim())
        .filter((s) => s !== "");
      const tint = parts.length ? optionTint(field, parts[0]) : undefined;
      return {
        kind: GridCellKind.Bubble,
        data: parts,
        allowOverlay: false,
        themeOverride: tint ? { bgBubble: tint.bg, textBubble: tint.fg } : undefined,
      };
    }
    default:
      // text / status-family fallthrough β€” phone and email ride this branch too: typed as
      // text in the grid, rendered as actionable tel:/mailto: links in the record drawer.
      return {
        kind: GridCellKind.Text,
        data: String(v ?? ""),
        displayData: String(v ?? ""),
        ...ro,
      };
  }
}