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