loopable / web /src /customer-grid /cells.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
665e5ea verified
Raw
History Blame Contribute Delete
37.3 kB
// ---------------------------------------------------------------------------
// 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,
};
}
}