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