// --------------------------------------------------------------------------- // 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 | 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; 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; /** * 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(); 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 = { 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 // 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; const _cellImgs = new Map(); 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 = { 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 = { 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 ): 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 ): 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, }; } }