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,
};
}
}
|