| // --------------------------------------------------------------------------- | |
| // customer-grid / iconShapes.ts | |
| // Wave-8 items I18 + I20 β ONE geometry source for the grid's icon vocabulary, | |
| // rendered by TWO very different painters: | |
| // | |
| // - React <FieldTypeIcon> / <ModeIcon> β DOM svg in panels, popovers, menus | |
| // - glide headerIcons sprites β canvas, drawn from an SVG *string* | |
| // | |
| // Glide's sprite API takes a function returning SVG SOURCE, so a header icon can | |
| // never be a React component. Keeping the paths as DATA (IconShape[]) and giving | |
| // each painter its own thin renderer is what stops the two from drifting β the | |
| // alternative (hand-copying every path into a template literal) guarantees the | |
| // header and the panel eventually disagree about what a "date" looks like. | |
| // | |
| // Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins, | |
| // currentColor. Vector paths only β NEVER emoji (owner constant). | |
| // --------------------------------------------------------------------------- | |
| import type { AggName, DisplayMode, FieldType, FolderShape, FolderTone } from "./types"; | |
| import { | |
| LP_BLUE, | |
| LP_BLUE_DEEP, | |
| LP_GREEN, | |
| LP_GREEN_DEEP, | |
| LP_LINE, | |
| LP_MUTED, | |
| LP_RED, | |
| LP_RED_DEEP, | |
| LP_YELLOW, | |
| LP_YELLOW_DEEP, | |
| } from "./theme"; | |
| /** One drawing primitive. `fill: true` fills the path instead of stroking it | |
| * (the rating star is the only shape that reads better solid). */ | |
| export type IconShape = { d: string; fill?: boolean }; | |
| /** A circle as a path β two half-arcs. Sprites are SVG *source*, so every shape | |
| * has to survive being serialized into a string; paths do, <circle> elements | |
| * would need a second serializer branch for no benefit. */ | |
| const circle = (cx: number, cy: number, r: number): string => | |
| `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`; | |
| const CALENDAR: IconShape[] = [ | |
| { d: "M3.2 4.6h9.6v8.2H3.2z" }, | |
| { d: "M3.2 7.2h9.6" }, | |
| { d: "M5.8 3v3.2" }, | |
| { d: "M10.2 3v3.2" }, | |
| ]; | |
| /** | |
| * Field type β icon geometry. A TOTAL record on purpose: adding a FieldType | |
| * without an icon is a compile error, not a silently blank header. | |
| */ | |
| export const TYPE_SHAPES: Record<FieldType, IconShape[]> = { | |
| text: [{ d: "M3 5h10M3 8h10M3 11h6" }], | |
| status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }], | |
| currency: [ | |
| { d: "M8 2.8v10.4" }, | |
| { d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" }, | |
| ], | |
| int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }], | |
| date: CALENDAR, | |
| pct: [ | |
| { d: circle(4.6, 4.6, 1.6) }, | |
| { d: circle(11.4, 11.4, 1.6) }, | |
| { d: "M12.2 3.9L3.8 12.3" }, | |
| ], | |
| select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }], | |
| user: [ | |
| { d: circle(8, 6, 2.4) }, | |
| { d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" }, | |
| ], | |
| multiselect: [ | |
| { d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" }, | |
| { d: "M7.2 5.7h6M7.2 10.3h6" }, | |
| ], | |
| checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }], | |
| phone: [ | |
| { d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" }, | |
| ], | |
| email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }], | |
| url: [ | |
| { d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" }, | |
| { d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" }, | |
| { d: "M6.2 9.8l3.6-3.6" }, | |
| ], | |
| rating: [ | |
| { | |
| d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z", | |
| fill: true, | |
| }, | |
| ], | |
| created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }], | |
| formula: [ | |
| { d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" }, | |
| { d: "M4.2 7.6h4.6" }, | |
| { d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" }, | |
| ], | |
| // Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring | |
| // with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly. | |
| // Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock | |
| // (`created_time` owns the closed rim + hands). | |
| automation: [ | |
| { d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" }, | |
| { d: "M4.2 6L5.3 4.1 3.1 4.5" }, | |
| { d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true }, | |
| ], | |
| // Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising | |
| // series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not | |
| // the formula fx (that computes over the ROW) and not a bare number (int owns ##). | |
| metric: [ | |
| { d: "M3.2 3.2v9.6h9.6" }, | |
| { d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" }, | |
| ], | |
| // Wave-23 C7 β TWO BRACES facing each other with a dot between them: the universal mark for | |
| // "a structured document", and the one glyph in this table that draws its own SYNTAX rather | |
| // than a picture of what the value means. Deliberately not a document page (nothing here owns | |
| // that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree | |
| // of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from | |
| // reading as parentheses at small sizes. | |
| json: [ | |
| { d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" }, | |
| { d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" }, | |
| { d: circle(8, 8, 0.85), fill: true }, | |
| ], | |
| // β Wave-27 item 13 (R13) β THE ANGLE BRACKETS, the mark every editor on earth uses for | |
| // "this is source". Drawn as two chevrons with a slash leaning between them, which is what | |
| // separates it from `json` two entries up: json draws BRACES (a document's own syntax), code | |
| // draws BRACKETS (a snippet's). Deliberately not a terminal prompt (that reads as "run this", | |
| // and R13 is explicit there is no execution engine) and not a page of lines (`list` mode and | |
| // `text` already trade on that reading). | |
| code: [ | |
| { d: "M5.6 4.9 2.6 8l3 3.1" }, | |
| { d: "M10.4 4.9 13.4 8l-3 3.1" }, | |
| { d: "M9.1 3.6 6.9 12.4" }, | |
| ], | |
| // Wave-19 R7 β a framed picture: the mount, a sun, and the hill line every photo glyph | |
| // resolves to at 16px. Drawn on the same 16-unit grid as its neighbours. | |
| image: [ | |
| { d: "M2.6 3.4h10.8v9.2H2.6z" }, | |
| { d: circle(6, 6.3, 1.1) }, | |
| { d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" }, | |
| ], | |
| // β 2026-08-07 β TWO INTERLOCKING CHAIN LINKS, the one glyph everybody already reads as | |
| // "this points at something else". Drawn as two rounded rectangles overlapping at the centre | |
| // rather than as an arrow into a box: an arrow would mean navigation, and a link column is a | |
| // relation that exists in both directions whether or not you follow it. | |
| link: [ | |
| { d: "M6.6 5.2H4.9a2.8 2.8 0 000 5.6h1.7" }, | |
| { d: "M9.4 5.2h1.7a2.8 2.8 0 010 5.6H9.4" }, | |
| { d: "M5.6 8h4.8" }, | |
| ], | |
| // β 2026-08-07 β THREE BARS FOLDING INTO ONE, read top-to-bottom: many linked values | |
| // collapsing to a single aggregate. Deliberately not a sigma (too fine at 16px, and it would | |
| // claim SUM when the function is chosen per column) and not a funnel (that is filtering, | |
| // which is what `limit` does β a different half of the same field). | |
| rollup: [ | |
| { d: "M3.2 4.4h9.6" }, | |
| { d: "M4.8 8h6.4" }, | |
| { d: "M6.6 11.6h2.8" }, | |
| ], | |
| }; | |
| /** | |
| * Display mode β icon geometry (I18). Also total: the wave-8 Dashboard mode | |
| * cannot land in DISPLAY_MODES without the compiler demanding its icon here. | |
| */ | |
| export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = { | |
| grid: [ | |
| { d: "M2.6 3.4h10.8v9.2H2.6z" }, | |
| { d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" }, | |
| ], | |
| list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }], | |
| // I19c β a framed set of bars: "several charts", not "one chart". | |
| chart: [ | |
| { d: "M2.6 3.4h10.8v9.2H2.6z" }, | |
| { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" }, | |
| ], | |
| // I10 (C2) β the LEGACY key. Kept so this record stays total over DisplayMode, which is | |
| // what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a | |
| // promise. Same drawing: a stored 'dashboard' IS a chart. | |
| dashboard: [ | |
| { d: "M2.6 3.4h10.8v9.2H2.6z" }, | |
| { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" }, | |
| ], | |
| calendar: CALENDAR, | |
| kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }], | |
| map: [ | |
| { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" }, | |
| { d: circle(8, 6.1, 1.3) }, | |
| ], | |
| // 2026-08-02 item 7 β the time-series view. Drawn as a framed grid with a trend running | |
| // through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with | |
| // a per-row line toggle. Deliberately not the plain `line` chart mark β a chart view and a | |
| // time-series table must not be the same picture in the same rail. | |
| timeseries: [ | |
| { d: "M2.6 3.4h10.8v9.2H2.6z" }, | |
| { d: "M2.6 6.6h10.8M6.2 3.4v9.2" }, | |
| { d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" }, | |
| ], | |
| // Wave-18 C6-CATALOG β an OPEN BOOK: two facing leaves with a spine between them, and a | |
| // product block sitting on the left one. Every other mode in this table draws a way of | |
| // arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here | |
| // with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and | |
| // not a framed grid (`grid`/`chart`/`timeseries` share the frame). | |
| catalog: [ | |
| { d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" }, | |
| { d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" }, | |
| { d: "M3.9 6.4h2.3v2.8H3.9z" }, | |
| ], | |
| // Wave-23 C9 β a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath | |
| // them. Every other mode here draws a way of ARRANGING records that already exist; this one | |
| // has to read as a record being MADE, so it is the only mark whose bottom line is open. | |
| // Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil | |
| // (an edit affordance means something else app-wide). | |
| // β Wave-27 C3 (owner item 8) β a CARD WITH TWO ARROWS LEAVING IT, left and right. Every | |
| // other mark here draws an arrangement of many records; this one has to read as ONE record | |
| // with two exits, because that is exactly what the deck is. Deliberately not a stack of cards | |
| // (that is a lane, and `kanban` owns it) and not a hand or a gesture glyph (nothing else in | |
| // this vocabulary draws a body part, and it would read as "drag" rather than "decide"). | |
| swipe: [ | |
| { d: "M5.4 3.4h5.2v9.2H5.4z" }, | |
| { d: "M3.6 8H1.4M2.8 6.8 1.4 8l1.4 1.2" }, | |
| { d: "M12.4 8h2.2M13.2 6.8 14.6 8l-1.4 1.2" }, | |
| ], | |
| form: [ | |
| { d: "M3.4 2.8h9.2v10.4H3.4z" }, | |
| { d: "M5.6 5.6h4.8" }, | |
| { d: "M5.6 8h4.8" }, | |
| { d: "M5.6 10.6h2.6" }, | |
| ], | |
| }; | |
| /** | |
| * Wave-9 I16 β one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's | |
| * vocabulary cannot ship without a drawing. | |
| * | |
| * β The owner asked for an icon on every chart-type option, and the wave-8 ruling stands: | |
| * an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type | |
| * picker is NOT a native `<select>` β it is a radio-row list like the mode switcher, which | |
| * is the only shape that can carry a real mark. | |
| * | |
| * The key type is a string union declared here rather than imported from chartData.ts: this | |
| * module is a leaf (types + theme only) and chartData imports IT, not the reverse. | |
| */ | |
| export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table"; | |
| export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = { | |
| bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }], | |
| line: [ | |
| { d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" }, | |
| { d: circle(5.8, 7.8, 0.9) }, | |
| { d: circle(8.4, 9.8, 0.9) }, | |
| ], | |
| area: [ | |
| { d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" }, | |
| { d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" }, | |
| ], | |
| donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }], | |
| // A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it | |
| // reads as "one number" beside four marks that all read as "a distribution". | |
| kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }], | |
| // Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of | |
| // values, not a distribution), with a header band and a column rule. | |
| table: [ | |
| { d: "M2.6 3.4h10.8v9.2H2.6z" }, | |
| { d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" }, | |
| ], | |
| }; | |
| export const CHART_KIND_LABELS: Record<ChartKindKey, string> = { | |
| bar: "Bar", | |
| line: "Line", | |
| area: "Area", | |
| donut: "Donut", | |
| kpi: "Single value", | |
| table: "Table", | |
| }; | |
| /** I16 β the pastel each chart kind wears, same family rule as MODE_TONE. */ | |
| export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = { | |
| bar: "blue", | |
| line: "green", | |
| area: "green", | |
| donut: "yellow", | |
| kpi: "neutral", | |
| table: "neutral", | |
| }; | |
| /** | |
| * Wave-9 contract C5 (I15) β folder icon geometry. TOTAL over `FolderShape`, so a shape key | |
| * added to the wire contract in types.ts cannot ship without a drawing. | |
| * | |
| * Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in | |
| * the same rail and read as one family. `folder` is first because it is the default every | |
| * pre-wave-9 folder falls back to (I14: "existing folders get the folder icon"). | |
| */ | |
| export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = { | |
| folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }], | |
| star: [ | |
| { d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" }, | |
| ], | |
| flag: [ | |
| { d: "M4.2 13.4V2.9" }, | |
| { d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" }, | |
| ], | |
| tag: [ | |
| { d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" }, | |
| { d: circle(5.6, 5.6, 1) }, | |
| ], | |
| bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }], | |
| // Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark. | |
| // Reusing the geometry rather than drawing a second "chart" is the whole point of the | |
| // one-source rule: a folder labelled Chart and the Chart view must not be two pictures. | |
| grid: MODE_SHAPES.grid, | |
| chart: MODE_SHAPES.dashboard, | |
| map: MODE_SHAPES.map, | |
| users: TYPE_SHAPES.user, | |
| clock: TYPE_SHAPES.created_time, | |
| heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }], | |
| bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }], | |
| }; | |
| /** | |
| * Tone key β the pastel it FILLS with, and the -d weight it STROKES with. | |
| * | |
| * Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit | |
| * that a base pastel at that size smudges β LP_BLUE measures 1.88:1 on white. So the pastel | |
| * is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the | |
| * outline (an outline that reads at all). | |
| * | |
| * The default tone is `neutral` β HOST's C5 key, not "grey". The whitelist is SHARED between | |
| * the two ends, so the name matters more than the word: a tone the host does not recognise | |
| * degrades to the default and the user's choice silently disappears on reload. | |
| */ | |
| export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = { | |
| neutral: { fill: LP_LINE, stroke: LP_MUTED }, | |
| blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP }, | |
| green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP }, | |
| yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP }, | |
| red: { fill: LP_RED, stroke: LP_RED_DEEP }, | |
| }; | |
| export const FOLDER_TONE_LABELS: Record<FolderTone, string> = { | |
| neutral: "Neutral", | |
| blue: "Blue", | |
| green: "Green", | |
| yellow: "Yellow", | |
| red: "Red", | |
| }; | |
| export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = { | |
| folder: "Folder", | |
| star: "Star", | |
| flag: "Flag", | |
| tag: "Tag", | |
| bookmark: "Bookmark", | |
| grid: "Table", | |
| chart: "Chart", | |
| map: "Map", | |
| users: "People", | |
| clock: "Clock", | |
| heart: "Heart", | |
| bolt: "Priority", | |
| }; | |
| /** | |
| * Wave-9 I14 β the tone each CREATABLE view type wears in the "+ Create newβ¦" flyout. | |
| * | |
| * The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey | |
| * is a list you read rather than scan. Assigned by family, not by rotation: the two | |
| * record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban) | |
| * share yellow, chart is green because it is the analytical one, map is red because it is | |
| * the geographic one. Folder is grey β it is not a view, and the flyout's last row should | |
| * not compete with the six above it. | |
| */ | |
| /** | |
| * Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label | |
| * sits beside the geometry, the way TYPE_LABELS does β the mode switcher, the create flyout | |
| * and the create prompt now read ONE table instead of three. C2's "Dashboard" β "Chart" | |
| * rename is a single line here as a direct result. | |
| */ | |
| export const MODE_LABELS: Record<DisplayMode, string> = { | |
| grid: "Grid", | |
| chart: "Chart", | |
| // Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view | |
| // read before normalisation must never render a blank switcher chip. | |
| dashboard: "Chart", | |
| list: "List", | |
| calendar: "Calendar", | |
| kanban: "Kanban", | |
| map: "Map", | |
| timeseries: "Time series", | |
| catalog: "Catalog", | |
| // Wave-23 C9 β the mode that COLLECTS records. "Form", the word the whole product uses for | |
| // it (the public page, the share panel, the `form_submitted` trigger); a synonym here would | |
| // be the one surface calling it something else. | |
| form: "Form", | |
| // β Wave-27 C3 (item 8) β the owner's own word for it. Not "Triage" or "Review": the gesture | |
| // IS the name here, and the two candidates both collide with vocabulary this product already | |
| // spends elsewhere (a `review` automation decision, the retired review lanes). | |
| swipe: "Swipe", | |
| }; | |
| /** | |
| * I14 β the view types the "+ Create newβ¦" flyout OFFERS, in the order it lists them. | |
| * | |
| * Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx: | |
| * C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the | |
| * rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β one list cannot | |
| * express both. Living in this pure data module means the gate can assert the offered set | |
| * without importing a React component, and I10 becomes a one-line edit in one file. | |
| */ | |
| // 2026-08-02 item 7 β `timeseries` was deliberately held OUT of this list until the host | |
| // accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2 | |
| // wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does | |
| // not know, so offering it early would let a user create a view that silently reverts to a | |
| // grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with | |
| // `DISPLAY_MODES += timeseries`, so it is offerable now. | |
| // | |
| // wave17 GRID, owner item 10 β THE ORDER BELOW IS THE OWNER'S, stated verbatim: | |
| // Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List. | |
| // | |
| // β It supersedes the two orderings this list has carried before it, and the reasoning that | |
| // produced them is now WRONG rather than merely outranked, so it is not left here to be | |
| // re-applied: `timeseries` was "listed last⦠it belongs beside Chart", and `list` sat second | |
| // as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is | |
| // a product decision, so it is asserted in `verify_icons` rather than left to a comment β | |
| // nothing else on screen would go red if a future edit re-sorted it "sensibly". | |
| // Wave-18 C6-CATALOG β `catalog` was held out of this list until `aios_grid.DISPLAY_MODES` | |
| // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted | |
| // "C6 HOST MIRROR APPLIED β you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable. | |
| // It lands LAST by contract: the owner's seven-mode order above is a product decision and the | |
| // new mode joins the end of it rather than being sorted into it. | |
| // β Wave-27 C3 (owner item 8) β `swipe` is HELD OUT of this list, and the reason corrects a | |
| // mistake this file made an hour earlier. | |
| // | |
| // β THE HOLD WAS NEVER ONLY ABOUT THE TWO REGISTRIES. It was first written as "do not offer a | |
| // mode the HOST has not accepted", and one session owning both registries this wave genuinely | |
| // does close that half β which is what made it tempting to skip. But the rule the hold really | |
| // encodes is broader and this wave proved it: **do not offer a mode whose CONSUMER does not | |
| // exist.** `swipe` was briefly listed here while `CustomerGrid`'s mode dispatch had no branch | |
| // for it, so picking "Swipe" wrote a mode the host now happily PERSISTS, and the body rendered | |
| // a grid under a chip reading "View Β· Swipe" β surviving reload, with the agreement leg green | |
| // because it only ever compared two lists. | |
| // | |
| // So the hold stood until `SwipeView` was mounted (contract C3), and it was not a mailbox | |
| // handshake: `verify_icons` DERIVES the condition β either `swipe` is absent here, or | |
| // `CustomerGrid.tsx` mounts `SwipeView`. `form` (wave-23) is the same defect from the other | |
| // direction, sitting unoffered because its host mirror never landed (DEBT D-90). | |
| // | |
| // β THE HOLD OUTLIVED THE WAVE, AND THAT IS THE LESSON WORTH MORE THAN THE FEATURE. | |
| // Wave 27 closed with the mount NEVER LANDING. `SwipeView.tsx` shipped, the server accepted | |
| // `swipe`, all four maps above carried it, 50 gates were green, the wave-27 mailbox recorded | |
| // "RESOLVED BY C (swipe mounted)", the close-out booked D-102 β a negative control FOR the | |
| // swipe carry β and the owner could not find the view because **nothing imported the file.** | |
| // The derived gate could not catch it: its condition is a DISJUNCTION and **absence satisfies | |
| // it**, so the unshipped state was permanently green ([[gate-can-report-green-on-nothing]]). | |
| // A hold that is safe to leave in place is a hold nothing forces you to lift. | |
| // The audit query that found it, after the battery did not: for each artifact a wave adds, grep | |
| // for its CONSUMER β who imports/mounts/registers it β excluding the file itself, `_test/`, css | |
| // and comments. `SwipeView` had four hits and three were prose. | |
| // Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable. | |
| // It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new | |
| // mode joins the end of it rather than being sorted into it. | |
| export const CREATABLE_MODES: DisplayMode[] = [ | |
| "grid", | |
| "chart", | |
| "calendar", | |
| "kanban", | |
| "timeseries", | |
| "map", | |
| "list", | |
| "catalog", | |
| "swipe", | |
| // Mounted 2026-08-11 (`CustomerGrid.tsx`, `displayMode === "form"` renders `FormInterface`), so | |
| // `form` is offerable and its `HELD_MODES` entry came out in the same edit β the hold's own text | |
| // named this mount as its release condition. β Found by `verify_icons.py::mode_parity` law E | |
| // during /validate-wave, NOT by the lane that mounted it: T29 mounted the component and T41 built | |
| // the Interface group, and between them the DOOR was never opened β the wave's biggest new | |
| // surface was unreachable behind two green tickets. | |
| "form", | |
| ]; | |
| /** | |
| * β WAVE-29 R6 (owner item 10) β the kind dropdown splits under TWO headers, and the strings are | |
| * the owner's own: exactly `View` and `Interface`. Not "View as" (what the popover said before), | |
| * not "Custom interface". | |
| * | |
| * The line the two groups draw: a **View** ARRANGES the records β the same rows, re-shaped (a | |
| * grid, a board, a deck, a chart). An **Interface** is a SURFACE BUILT OVER them: a map is a | |
| * picture of the world with records placed on it, a catalog is a published artifact, a form is a | |
| * door records come IN through and shows no records at all. It is the same distinction | |
| * `MODE_TONE` below already draws by colour β the four tones name families of ways to arrange | |
| * records, and `catalog`/`form` are NEUTRAL there precisely because they arrange nothing. | |
| * | |
| * β TOTAL over `DisplayMode`, so tsc refuses a new mode with no group rather than letting it | |
| * vanish from the dropdown: membership is derived by FILTERING `CREATABLE_MODES` through this | |
| * map, and a mode whose group label matched nothing would silently stop being offered while | |
| * every existing check (paintable, labelled, toned, unique, ordered) stayed green. | |
| * `dashboard` is grouped like the `chart` it is the legacy spelling of β it is never offered, and | |
| * a partial map is a worse answer than an unused entry. | |
| */ | |
| export const MODE_GROUP_LABELS = ["View", "Interface"] as const; | |
| export type ModeGroup = (typeof MODE_GROUP_LABELS)[number]; | |
| export const MODE_GROUP: Record<DisplayMode, ModeGroup> = { | |
| grid: "View", | |
| list: "View", | |
| kanban: "View", | |
| calendar: "View", | |
| chart: "View", | |
| dashboard: "View", | |
| timeseries: "View", | |
| swipe: "View", | |
| map: "Interface", | |
| catalog: "Interface", | |
| form: "Interface", | |
| }; | |
| /** | |
| * The offered modes, split into R6's two groups β DERIVED, never a third hand-written list. | |
| * | |
| * β ORDER: R6 names each group's MEMBERS; the order inside a group stays `CREATABLE_MODES`', which | |
| * is the wave-17 owner ruling ("Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List") and | |
| * is separately asserted. The two rulings are compatible read this way and only this way: R6 moved | |
| * `map` out of the run of views, so wave-17's single sequence can no longer exist as one list, but | |
| * every pair it ordered is still in that relative order here. | |
| */ | |
| export const CREATABLE_GROUPS: readonly { label: ModeGroup; modes: DisplayMode[] }[] = | |
| MODE_GROUP_LABELS.map((label) => ({ | |
| label, | |
| modes: CREATABLE_MODES.filter((m) => MODE_GROUP[m] === label), | |
| })); | |
| export const MODE_TONE: Record<DisplayMode, FolderTone> = { | |
| grid: "blue", | |
| list: "blue", | |
| chart: "green", | |
| dashboard: "green", | |
| calendar: "yellow", | |
| kanban: "yellow", | |
| map: "red", | |
| // Green with `chart`: it is the other analytical mode, and the two belong to one family. | |
| timeseries: "green", | |
| // Wave-18 C6-CATALOG β NEUTRAL, and it is the honest pick rather than the leftover one. The | |
| // four colour tones each name a family of ways to arrange records (blue = tabular, green = | |
| // analytical, yellow = board/date, red = spatial); a catalog arranges nothing β it is a | |
| // published artifact. Giving it a colour would file it under a family it is not in. | |
| catalog: "neutral", | |
| // Wave-23 C9 β NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones | |
| // name families of ways to ARRANGE records (blue tabular, green analytical, yellow | |
| // board/date, red spatial). A form arranges nothing β it is a door records come in through β | |
| // so giving it a colour would file it under a family it is not in. | |
| form: "neutral", | |
| // β Wave-27 C3 β YELLOW, with `kanban`, and this is a family claim rather than a leftover: | |
| // a swipe deck writes the SAME single-select a kanban stacks by (R2 binds it to one), so the | |
| // two are one family seen at two zooms β all the lanes at once, or one card at a time. Filing | |
| // it neutral (the `catalog`/`form` reasoning) would be wrong for the opposite reason those | |
| // two are neutral: this mode does arrange records, and it arranges them by the board's field. | |
| swipe: "yellow", | |
| }; | |
| /** | |
| * Human labels for every field type. Lives here beside the icons so the two | |
| * halves of "how a field type presents itself" stay in one file (ColumnMenu | |
| * imports it rather than keeping a second copy). | |
| */ | |
| export const TYPE_LABELS: Record<FieldType, string> = { | |
| // β WAVE-29 item 3 β the owner's own words: "Change 'Single line text' to 'Text', keep it | |
| // simple for the Field type". Airtable's phrase described the column's SHAPE (one line, versus | |
| // its long-text sibling); this product has no multi-line text kind, so the qualifier | |
| // distinguished the type from nothing and only made the commonest row in the menu the longest. | |
| // β The STORED key is `"text"` and always was β the old string was never persisted anywhere, | |
| // client or server, so this is a label change with no migration behind it. | |
| text: "Text", | |
| select: "Single select", | |
| multiselect: "Multi select", | |
| user: "Assignee", | |
| int: "Number", | |
| currency: "Currency", | |
| pct: "Percent", | |
| date: "Date", | |
| checkbox: "Checkbox", | |
| phone: "Phone number", | |
| email: "Email", | |
| url: "URL", | |
| rating: "Rating", | |
| created_time: "Created time", | |
| formula: "Formula", | |
| // Wave-18 C5-AUTOFIELD (D's spec, applied by C). | |
| automation: "Automation", | |
| // Wave-22 C7 β spawned by automations (not in CREATABLE_TYPES), so this label mostly shows | |
| // on headers and the field gear, not the create menu. | |
| metric: "Metric", | |
| // Wave-19 R7 β the picture column. | |
| image: "Image", | |
| // Wave-23 C7 β the structured-document column. "JSON" rather than "Structured data": it is | |
| // the word on the wire, in the viewer's raw tab and in every error the server can return, and | |
| // a friendlier synonym would be the only place in the product using a different one. | |
| json: "JSON", | |
| // β 2026-08-07 β Airtable's own wording, deliberately. "Link to another record" is what a | |
| // person migrating from Airtable searches this menu for, and inventing a synonym ("Relation", | |
| // "Reference") would make the feature they came for look absent. | |
| link: "Link to another record", | |
| rollup: "Rollup", | |
| // β Wave-27 item 13 (R13) β "Code", not "Snippet" or "Source": it is the word the field kind | |
| // is called everywhere else in this wave (the ruling, the language picker, the viewer header), | |
| // and it says what the column holds without implying the product will run it. | |
| code: "Code", | |
| status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total | |
| }; | |
| /** | |
| * β WAVE-29 C7 (item 17) β THE COLUMN-SUMMARY vocabulary: what a field's `agg` may be, which is | |
| * what the totals row and the per-group subtotals compute. Server twin: | |
| * `platform/aios_grid.py::FIELD_AGGS`, and `verify_icons.py::agg_parity` reads BOTH FILES and | |
| * compares them name-for-name in order β the cross-language boundary is the one a type cannot | |
| * police, so it gets a gate. | |
| * | |
| * β ONE CLIENT LIST, IMPORTED β never re-declared. `aggregations.ts` and the field editor import | |
| * from here rather than keeping their own copy, which is why this lives in the pure data module | |
| * beside `TYPE_LABELS` and `CREATABLE_MODES`: a second client list would need a second gate, and | |
| * the two would drift in the direction nobody is watching. C7 says "C publishes, E mirrors"; a | |
| * mirror that is an import cannot fall out of step at all. | |
| * | |
| * β NOT the chart vocabulary. `CHART_AGGS` (`aios_grid.py`, `viz/chartData.ts`) spells it `avg` | |
| * and gatekeeps a STORED value β renaming it would silently turn saved charts into sums. This | |
| * list spells it `average`, matching `ROLLUP_FNS` (16 names, live in production), so a column | |
| * summary and a rollup fold say the same word for the same operation. | |
| * | |
| * β `median` is net-new β in neither `CHART_AGGS` nor `ROLLUP_FNS`. | |
| * β `count` counts ROWS in the scope, not non-blank cells. | |
| * β Which types may carry which: `sum/average/median/min/max` are numeric-only and the evaluator | |
| * for that is ALREADY `isNumericFieldType` (types.ts) β do not write a second one. `count` is | |
| * legal on any type. | |
| */ | |
| // β W29-T74 β an ALIAS of `types.AggName`, not a fifth copy of the union. `Field.agg` is typed | |
| // `AggName`, so a second literal here would be a type that has to be kept in step by eye with a | |
| // type the compiler already owns β the same defect as the array below, one level up. | |
| export type FieldAgg = AggName; | |
| /** ORDERED β the order is the picker's order, on both engines. */ | |
| export const FIELD_AGGS: readonly FieldAgg[] = [ | |
| "sum", | |
| "average", | |
| "median", | |
| "min", | |
| "max", | |
| "count", | |
| ]; | |
| /** | |
| * Human labels, in the summary bar's own compact register (Airtable's wording). | |
| * | |
| * β `FIELD_AGG_LABELS`, not `AGG_LABELS`, and the prefix is load-bearing: `viewModes.tsx` already | |
| * has a module-local `AGG_LABELS` for the CALENDAR summary picker over `CHART_AGGS`, where the | |
| * same five names wear different words ("Total", "Lowest", "Highest") for a day cell. Two tables | |
| * called `AGG_LABELS` describing two vocabularies is how a future import lands on the wrong one. | |
| */ | |
| export const FIELD_AGG_LABELS: Record<FieldAgg, string> = { | |
| sum: "Sum", | |
| average: "Average", | |
| median: "Median", | |
| min: "Min", | |
| max: "Max", | |
| count: "Count", | |
| }; | |
| // ------------------------------------------------------------ glide sprites | |
| /** Serialize one shape to SVG source in an explicit colour (canvas sprites get | |
| * no `currentColor` β glide hands the painter the theme colours directly). */ | |
| function shapeSource(s: IconShape, color: string): string { | |
| return s.fill | |
| ? `<path d="${s.d}" fill="${color}"/>` | |
| : `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` + | |
| `stroke-linecap="round" stroke-linejoin="round"/>`; | |
| } | |
| function sprite(shapes: IconShape[]) { | |
| return ({ fgColor }: { fgColor: string }) => | |
| `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` + | |
| shapes.map((s) => shapeSource(s, fgColor)).join("") + | |
| `</svg>`; | |
| } | |
| /** Glide header-icon NAME for a field type β the `icon` a GridColumn asks for. */ | |
| export function typeIconName(type: FieldType): string { | |
| return `t_${type}`; | |
| } | |
| /** | |
| * The sprite map handed to <DataEditor headerIcons>. One entry per field type | |
| * (I20 draws the type mark in every column header), built from the same shapes | |
| * the React icons use. | |
| * | |
| * Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is | |
| * why theme.ts must set it β the library default is #FFFFFF, i.e. invisible on | |
| * our header (that was I21's actual bug, not a too-pale hex of ours). | |
| */ | |
| export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> = | |
| Object.fromEntries( | |
| (Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])]) | |
| ); | |
| /** | |
| * The header sprite map handed to <DataEditor headerIcons>. Two families: | |
| * | |
| * t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header, | |
| * from the same shapes the React icons use. Painted by glide in | |
| * `theme.fgIconHeader`. | |
| * aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i). | |
| * OUTLINE ONLY: a dark-grey ring with a transparent interior, per | |
| * the owner. It still deliberately IGNORES the colours glide hands | |
| * it, for the reason wave-8 recorded - glide's "special" variant is | |
| * accentColor behind bgHeader, which under the C1 pastels is a pale | |
| * glyph on a pale disc, i.e. I21 in a new costume. | |
| * β It is NO LONGER a column `overlayIcon`. Glide draws an overlay | |
| * at a hard-coded offset from the TYPE mark on the far LEFT of the | |
| * header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT- | |
| * aligned. It is now painted by CustomerGrid's `drawHeader` | |
| * callback at `infoMarkRect()` - see overlayPlacement.ts. | |
| */ | |
| export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = { | |
| ...TYPE_SPRITES, | |
| aiosInfo: () => | |
| `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` + | |
| `<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` + | |
| `<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` + | |
| `stroke-linecap="round"/>` + | |
| `<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` + | |
| `</svg>`, | |
| }; | |