fsanyoto commited on
Commit
fee4db3
Β·
verified Β·
1 Parent(s): a6313df

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,6 +1,12 @@
1
  {
2
- "current": "v51 (5740d20)",
3
  "releases": [
 
 
 
 
 
 
4
  {
5
  "version": "v51",
6
  "sha": "5740d20",
 
1
  {
2
+ "current": "v52 (8263493)",
3
  "releases": [
4
+ {
5
+ "version": "v52",
6
+ "sha": "8263493",
7
+ "date": "2026-08-23",
8
+ "subject": "release v52"
9
+ },
10
  {
11
  "version": "v51",
12
  "sha": "5740d20",
VERSION CHANGED
@@ -1 +1 @@
1
- v51 (5740d20)
 
1
+ v52 (8263493)
platform/aios_grid.py CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/useGridColumns.ts CHANGED
@@ -1,391 +1,422 @@
1
- import { useCallback, useMemo } from "react";
2
- import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
3
- import type { Field, FilterNode, ViewConfig } from "./types";
4
- import { isFilterGroup, isRuleActive, measureColumnIndex, ruleColumnKeys } from "./types";
5
- import { typeIconName } from "./iconShapes";
6
- import { fitHeaderTitle, headerLabelSpace, headerMarkSizes } from "./overlayPlacement";
7
- import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
8
- import type { ControlTone } from "./theme";
9
-
10
- const DEFAULT_WIDTH = 150;
11
-
12
- /**
13
- * Owner item 16 β€” measure header text in GLIDE'S OWN HEADER FONT.
14
- *
15
- * ⚠ NOT the cell font. Glide's `headerFontStyle` is "600 13px" and its `baseFontStyle` is
16
- * "13px" (`common/styles.js:73-75`); semibold is materially wider, so measuring with the cell
17
- * font under-measures, under-truncates, and leaves the label running under the (i) β€” the exact
18
- * defect this item is about, surviving a fix that looked correct in the diff.
19
- *
20
- * ⚠⚠ Wave-14 item 2 adds the SAME trap one weight up. An involved column paints its header at
21
- * **700** (`INVOLVED_HEADER_FONT`), so measuring every column at 600 would under-truncate
22
- * exactly the filtered/sorted/grouped columns β€” the ones the owner is looking at. Hence TWO
23
- * cached contexts, and both font strings are read off the theme layer rather than retyped here:
24
- * a literal in this file is a copy that can drift from the one glide actually paints with, which
25
- * is the whole failure mode above.
26
- *
27
- * ⚠ Wave-15 R5 gave each control its OWN hue, so there are now three involved-column themes
28
- * instead of one. They deliberately share `INVOLVED_HEADER_FONT`, so ONE bold context still
29
- * measures all three correctly β€” but the constant, not any one of the three theme objects, is
30
- * what this reads. Picking `COLUMN_TONE_THEME.filter.headerFontStyle` would have measured every
31
- * involved column against whichever tone happened to be listed first.
32
- *
33
- * One lazily-built offscreen context per weight, the same shape as CustomerGrid's
34
- * `measureCellText`. A context can legitimately be null (a headless canvas, a locked-down
35
- * embed); 0 then measures as "everything fits" and no title is truncated, which is the safe
36
- * direction to fail β€” a title that slightly overlaps beats one silently cut to "C…".
37
- */
38
- const _hdrCtx: Record<"base" | "bold", CanvasRenderingContext2D | null | undefined> = {
39
- base: undefined,
40
- bold: undefined,
41
- };
42
- function measureHeaderText(text: string, bold = false): number {
43
- const slot = bold ? "bold" : "base";
44
- if (_hdrCtx[slot] === undefined) {
45
- const ctx = document.createElement("canvas").getContext("2d");
46
- if (ctx) {
47
- const style =
48
- (bold ? INVOLVED_HEADER_FONT : lightTheme.headerFontStyle) ?? "600 13px";
49
- ctx.font = `${style} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
50
- }
51
- _hdrCtx[slot] = ctx;
52
- }
53
- const ctx = _hdrCtx[slot];
54
- return ctx ? ctx.measureText(text).width : 0;
55
- }
56
-
57
- /** The (i) is drawn for exactly this condition β€” `CustomerGrid.drawGridHeader` resolves the
58
- * same one from the same field, so the reserve and the mark can never disagree. */
59
- function hasInfoMark(field: Field): boolean {
60
- return !!(field.note || field.description);
61
- }
62
-
63
- /** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
64
- * rules are skipped: a rule that is not narrowing anything must not tint a column as though
65
- * it were β€” that is the same "it looks like it is working" lie the engine refuses to tell.
66
- *
67
- * ⚠ Wave-20 item 2: the key a rule NAMES is not always the column it is ABOUT β€” a measure
68
- * condition carries the MEASURE key. `ruleColumnKeys` is the one resolution (types.ts); it
69
- * is what stopped a filtered-and-sorted measure column wearing the sort hue. */
70
- function filteredKeys(
71
- nodes: FilterNode[],
72
- out: Set<string>,
73
- measureCols: Map<string, string[]>
74
- ): void {
75
- for (const node of nodes ?? []) {
76
- if (isFilterGroup(node)) {
77
- filteredKeys(node.children, out, measureCols);
78
- continue;
79
- }
80
- if (isRuleActive(node)) for (const key of ruleColumnKeys(node, measureCols)) out.add(key);
81
- }
82
- }
83
-
84
- /**
85
- * The column's theme override, or `undefined` β€” glide's fast path for "no override" is an absent
86
- * object, so an empty `{}` would cost a ~35-key theme merge per column per frame.
87
- *
88
- * ⚠ Wave-14 item 1 (R11): a user-created field contributes no theme. Its yellow header wash is
89
- * dead and its replacement is a painted DOT, not a background β€” so the only thing that can put a
90
- * themeOverride on a column is being INVOLVED in a control.
91
- *
92
- * ⚠ Wave-15 R5: the returned object is one of the three SHARED `COLUMN_TONE_THEME` references
93
- * (never a fresh object), which is what keeps glide's theme merge cache-friendly across frames.
94
- *
95
- * β›” Wave-29 R8 (2026-08-11): those three references now carry **HEADER keys only**. glide builds a
96
- * header theme from the column override alone and a cell theme from column β†’ row οΏ½οΏ½οΏ½ cell
97
- * (`data-grid-render.header.js:59`, `common/styles.js mergeAndRealizeTheme`), so the tone reaching
98
- * a column theme is exactly how a sort used to repaint record bodies. With no `bgCell` in the
99
- * table there is no longer a path from "this column is sorted" to a cell's background at all β€”
100
- * which is a property of what `COLUMN_TONE_THEME` CONTAINS, not of anything this function does.
101
- * A body wash belongs to `getRowThemeOverride` (status) or the cell itself (automation).
102
- */
103
- function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined {
104
- return tone ? COLUMN_TONE_THEME[tone] : undefined;
105
- }
106
-
107
- /**
108
- * Owner item 22 β€” key β†’ which control involves it, or absent.
109
- *
110
- * ⚠ ONE tone per column, and the precedence is a decision, not an accident: **filter > sort >
111
- * group** (owner R6, wave 15). A filter changes WHICH ROWS you are looking at; when a column is
112
- * doing two jobs the header names the most consequential one. Blending two tints would produce a
113
- * third colour that matches no chip in the toolbar, which is worse than picking.
114
- *
115
- * ⚠ THE PRECEDENCE IS THE WRITE ORDER BELOW, and it reads backwards: later `set` calls WIN, so
116
- * the least important control is written FIRST. It was `sort, group, filter` β€” i.e. filter >
117
- * group > sort β€” until R6 named the order explicitly; getting this wrong is a one-line edit that
118
- * changes a colour on screen and nothing else, so it is stated here rather than left to be
119
- * re-derived from the sequence.
120
- */
121
- /* wave17 GRID β€” item 2 / owner R5. EXPORTED, and only since the row band died.
122
- `activeControlTone` used to sit below this function and restate the same precedence for the
123
- whole-table band; the band was its only consumer, so R5 took both. That left the surviving
124
- precedence β€” the one that still paints β€” asserted by nothing, because `verify_icons` was
125
- testing the twin. Exported so the gate can reach the function that is actually on screen. */
126
- export function columnTones(
127
- config: ViewConfig,
128
- /* wave20 item 2 β€” the FIELDS, because a measure condition names its measure and only the
129
- field list can say which column displays it. Defaulted so the signature stays callable
130
- with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
131
- legitimately has no measure columns to resolve. */
132
- fields: Field[] = []
133
- ): Map<string, ControlTone> {
134
- const out = new Map<string, ControlTone>();
135
- if (config.groupBy) out.set(config.groupBy, "group");
136
- for (const s of config.sorts ?? []) out.set(s.colId, "sort");
137
- const filtered = new Set<string>();
138
- filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
139
- for (const key of filtered) out.set(key, "filter");
140
- return out;
141
- }
142
-
143
- /**
144
- * ⭐ WAVE 30 (owner item 2 scouting) β€” THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
145
- *
146
- * This read `field.default !== false || field.source === "overlay"`. The second arm was written
147
- * when `overlay` meant "a column a user added here", and those columns have no `default` key at
148
- * all β€” so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
149
- * stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
150
- * one: the exception quietly became the rule and swallowed the first arm entirely.
151
- *
152
- * β›” WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
153
- * `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
154
- * opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
155
- * `shownCount === fields.length` β€” so the control that says "some columns are hidden" says the
156
- * opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
157
- *
158
- * ⚠ DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
159
- * believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
160
- * true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
161
- * `ut_*` column can never arrive carrying `default: false` by accident β€” only a shipped field
162
- * CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
163
- *
164
- * ⚠ THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
165
- * `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
166
- * records" system view the client pins as its landing default. Fixing one side alone changes
167
- * nothing a user sees β€” see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
168
- * languages).
169
- */
170
- function isDefaultVisible(field: Field): boolean {
171
- return field.default !== false;
172
- }
173
-
174
- export function defaultViewConfig(fields: Field[]): ViewConfig {
175
- const shown = fields.filter(isDefaultVisible).map((field) => field.key);
176
- const hidden = fields.filter((field) => !isDefaultVisible(field)).map((field) => field.key);
177
- return {
178
- filters: [],
179
- filterConj: "and",
180
- sorts: [],
181
- groupBy: null,
182
- colorBy: null,
183
- rowHeightMode: "short",
184
- order: [...shown, ...hidden],
185
- visible: shown,
186
- widths: {},
187
- memberPids: [],
188
- };
189
- }
190
-
191
- function reconcileOrder(order: string[], fields: Field[]): string[] {
192
- const valid = new Set(fields.map((field) => field.key));
193
- const kept = order.filter((key, index) => valid.has(key) && order.indexOf(key) === index);
194
- for (const field of fields) if (!kept.includes(field.key)) kept.push(field.key);
195
- const locked = fields.find((field) => field.pinned)?.key ?? fields[0]?.key;
196
- if (locked && kept[0] !== locked) {
197
- const without = kept.filter((key) => key !== locked);
198
- return [locked, ...without];
199
- }
200
- return kept;
201
- }
202
-
203
- function reconcileVisible(visible: string[], fields: Field[], lockedKey: string): Set<string> {
204
- const valid = new Set(fields.map((field) => field.key));
205
- const out = new Set(visible.filter((key) => valid.has(key)));
206
- if (out.size === 0) {
207
- for (const field of fields) if (isDefaultVisible(field)) out.add(field.key);
208
- }
209
- if (lockedKey) out.add(lockedKey);
210
- return out;
211
- }
212
-
213
- export interface GridColumnsApi {
214
- visibleCols: GridColumn[];
215
- fieldByKey: Map<string, Field>;
216
- order: string[];
217
- visible: Set<string>;
218
- widths: Record<string, number>;
219
- lockedKey: string;
220
- onColumnResize: (col: GridColumn, newSize: number) => void;
221
- onColumnMoved: (from: number, to: number) => void;
222
- onColumnProposeMove: (from: number, to: number) => boolean;
223
- setColumnVisible: (key: string, show: boolean) => void;
224
- insertColumn: (key: string, anchorKey: string | null, side: "left" | "right" | "end") => void;
225
- }
226
-
227
- /**
228
- * Controlled column adapter. ViewConfig is the durable source of truth, so
229
- * choosing a saved view is one atomic state change instead of a chain of hook
230
- * setters that can flash or overwrite one another.
231
- */
232
- export function useGridColumns(
233
- fields: Field[],
234
- config: ViewConfig,
235
- onConfig: (next: ViewConfig) => void
236
- ): GridColumnsApi {
237
- const fieldByKey = useMemo(
238
- () => new Map(fields.map((field) => [field.key, field])),
239
- [fields]
240
- );
241
- const lockedKey = useMemo(
242
- () => fields.find((field) => field.pinned)?.key ?? fields[0]?.key ?? "",
243
- [fields]
244
- );
245
- const order = useMemo(() => reconcileOrder(config.order, fields), [config.order, fields]);
246
- const visible = useMemo(
247
- () => reconcileVisible(config.visible, fields, lockedKey),
248
- [config.visible, fields, lockedKey]
249
- );
250
-
251
- // Item 22 β€” recomputed only when the three controls move, not on every width drag.
252
- // Wave-20 item 2 adds `fields`: a measure condition can only be resolved to the column that
253
- // displays it, and a new measure column must re-tint without waiting for a control to move.
254
- const tones = useMemo(
255
- () => columnTones(config, fields),
256
- [config.filters, config.sorts, config.groupBy, fields] // eslint-disable-line react-hooks/exhaustive-deps
257
- );
258
-
259
- const visibleCols = useMemo<GridColumn[]>(
260
- () =>
261
- order
262
- .filter((key) => visible.has(key))
263
- .map((key) => {
264
- const field = fieldByKey.get(key)!;
265
- const width = config.widths[key] ?? DEFAULT_WIDTH;
266
- // Wave-14 item 2 β€” an INVOLVED column's header paints at 700, so it must be MEASURED
267
- // at 700. `tones` is already a dep of this memo, so this costs nothing.
268
- const involved = tones.has(key);
269
- // R11 β€” the marks this header carries, in the strip's canonical order. Both this
270
- // reserve and `CustomerGrid.drawGridHeader`'s painting read `headerMarkSizes`, so a
271
- // column can never reserve room for one mark and then be given two.
272
- const marks = headerMarkSizes(hasInfoMark(field), field.source === "overlay");
273
- return {
274
- id: key,
275
- // Owner item 16 β€” ELLIPSISED so the title can never run under the (i). Glide
276
- // truncates nothing: `drawHeaderInner` calls `fillText(c.title, …)` with no clip
277
- // and no max width, so on a narrow column the name simply painted through the
278
- // mark. Shortening the title glide is given is the whole fix β€” the alternative,
279
- // clipping inside `drawHeader`, cuts mid-glyph with no ellipsis to say it did.
280
- // The FULL name stays reachable: CustomerGrid's header tip leads with it whenever
281
- // this returns something shorter than `field.label`.
282
- title: fitHeaderTitle(
283
- field.label,
284
- headerLabelSpace(width, marks),
285
- (text) => measureHeaderText(text, involved)
286
- ),
287
- width,
288
- hasMenu: true,
289
- menuIcon: "dots",
290
- // Wave-8 I20 β€” the field-TYPE mark leads every header, so the column says what
291
- // KIND of thing it holds before you read a single cell (same geometry as the
292
- // Fields panel's rows β€” icons.tsx).
293
- icon: typeIconName(field.type),
294
- // Wave-9 I3 β€” the (i) is NO LONGER `overlayIcon`. Glide draws an overlay at a
295
- // hard-coded offset from the TYPE mark (drawHeaderInner: `drawX + 9`), i.e.
296
- // pinned to the far LEFT of the header, and no prop moves it; the owner asked
297
- // for it right-aligned and centred with the field name. It is drawn instead by
298
- // CustomerGrid's `drawHeader` callback, which resolves the same
299
- // `field.note || field.description` condition from the field itself.
300
- // The hover text is still CustomerGrid's floating tip (pointer-events: none β€”
301
- // a tooltip must never swallow the next click, [[ui-invisible-to-assertions]]).
302
- // Wave-14 R4 β€” being INVOLVED in a filter/sort/group is the only thing that tints a
303
- // column now, and all three tint it the same faint warm grey; the hue that says
304
- // WHICH control lives on the toolbar chip. User-created ownership moved out of the
305
- // theme entirely and is the dot drawn in the mark strip (R11).
306
- themeOverride: columnTheme(tones.get(key)),
307
- };
308
- }),
309
- [order, visible, config.widths, fieldByKey, tones]
310
- );
311
-
312
- const onColumnResize = useCallback(
313
- (column: GridColumn, newSize: number) => {
314
- if (!column.id) return;
315
- onConfig({
316
- ...config,
317
- widths: { ...config.widths, [column.id]: Math.round(newSize) },
318
- });
319
- },
320
- [config, onConfig]
321
- );
322
-
323
- const onColumnMoved = useCallback(
324
- (from: number, to: number) => {
325
- if (from === 0 || to === 0) return;
326
- const visibleKeys = order.filter((key) => visible.has(key));
327
- if (
328
- from < 0 ||
329
- from >= visibleKeys.length ||
330
- to < 0 ||
331
- to >= visibleKeys.length
332
- )
333
- return;
334
- const movedKeys = visibleKeys.slice();
335
- const [moved] = movedKeys.splice(from, 1);
336
- movedKeys.splice(to, 0, moved);
337
- let index = 0;
338
- const nextOrder = order.map((key) =>
339
- visible.has(key) ? movedKeys[index++] : key
340
- );
341
- onConfig({ ...config, order: nextOrder });
342
- },
343
- [config, onConfig, order, visible]
344
- );
345
-
346
- const onColumnProposeMove = useCallback(
347
- (from: number, to: number) => from !== 0 && to !== 0,
348
- []
349
- );
350
-
351
- const setColumnVisible = useCallback(
352
- (key: string, show: boolean) => {
353
- if (!show && key === lockedKey) return;
354
- const next = new Set(visible);
355
- if (show) next.add(key);
356
- else next.delete(key);
357
- onConfig({ ...config, visible: [...next] });
358
- },
359
- [config, lockedKey, onConfig, visible]
360
- );
361
-
362
- const insertColumn = useCallback(
363
- (key: string, anchorKey: string | null, side: "left" | "right" | "end") => {
364
- const nextOrder = order.filter((item) => item !== key);
365
- if (side === "end" || !anchorKey) {
366
- nextOrder.push(key);
367
- } else {
368
- const anchor = Math.max(0, nextOrder.indexOf(anchorKey));
369
- nextOrder.splice(anchor + (side === "right" ? 1 : 0), 0, key);
370
- }
371
- const nextVisible = new Set(visible);
372
- nextVisible.add(key);
373
- onConfig({ ...config, order: nextOrder, visible: [...nextVisible] });
374
- },
375
- [config, onConfig, order, visible]
376
- );
377
-
378
- return {
379
- visibleCols,
380
- fieldByKey,
381
- order,
382
- visible,
383
- widths: config.widths,
384
- lockedKey,
385
- onColumnResize,
386
- onColumnMoved,
387
- onColumnProposeMove,
388
- setColumnVisible,
389
- insertColumn,
390
- };
391
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useMemo } from "react";
2
+ import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
3
+ import type { Field, FilterNode, ViewConfig } from "./types";
4
+ import { isFilterGroup, isRuleActive, measureColumnIndex, ruleColumnKeys } from "./types";
5
+ import { typeIconName } from "./iconShapes";
6
+ import { fitHeaderTitle, headerLabelSpace, headerMarkSizes } from "./overlayPlacement";
7
+ import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
8
+ import type { ControlTone } from "./theme";
9
+
10
+ const DEFAULT_WIDTH = 150;
11
+
12
+ /**
13
+ * Owner item 16 β€” measure header text in GLIDE'S OWN HEADER FONT.
14
+ *
15
+ * ⚠ NOT the cell font. Glide's `headerFontStyle` is "600 13px" and its `baseFontStyle` is
16
+ * "13px" (`common/styles.js:73-75`); semibold is materially wider, so measuring with the cell
17
+ * font under-measures, under-truncates, and leaves the label running under the (i) β€” the exact
18
+ * defect this item is about, surviving a fix that looked correct in the diff.
19
+ *
20
+ * ⚠⚠ Wave-14 item 2 adds the SAME trap one weight up. An involved column paints its header at
21
+ * **700** (`INVOLVED_HEADER_FONT`), so measuring every column at 600 would under-truncate
22
+ * exactly the filtered/sorted/grouped columns β€” the ones the owner is looking at. Hence TWO
23
+ * cached contexts, and both font strings are read off the theme layer rather than retyped here:
24
+ * a literal in this file is a copy that can drift from the one glide actually paints with, which
25
+ * is the whole failure mode above.
26
+ *
27
+ * ⚠ Wave-15 R5 gave each control its OWN hue, so there are now three involved-column themes
28
+ * instead of one. They deliberately share `INVOLVED_HEADER_FONT`, so ONE bold context still
29
+ * measures all three correctly β€” but the constant, not any one of the three theme objects, is
30
+ * what this reads. Picking `COLUMN_TONE_THEME.filter.headerFontStyle` would have measured every
31
+ * involved column against whichever tone happened to be listed first.
32
+ *
33
+ * One lazily-built offscreen context per weight, the same shape as CustomerGrid's
34
+ * `measureCellText`. A context can legitimately be null (a headless canvas, a locked-down
35
+ * embed); 0 then measures as "everything fits" and no title is truncated, which is the safe
36
+ * direction to fail β€” a title that slightly overlaps beats one silently cut to "C…".
37
+ */
38
+ const _hdrCtx: Record<"base" | "bold", CanvasRenderingContext2D | null | undefined> = {
39
+ base: undefined,
40
+ bold: undefined,
41
+ };
42
+ function measureHeaderText(text: string, bold = false): number {
43
+ const slot = bold ? "bold" : "base";
44
+ if (_hdrCtx[slot] === undefined) {
45
+ const ctx = document.createElement("canvas").getContext("2d");
46
+ if (ctx) {
47
+ const style =
48
+ (bold ? INVOLVED_HEADER_FONT : lightTheme.headerFontStyle) ?? "600 13px";
49
+ ctx.font = `${style} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
50
+ }
51
+ _hdrCtx[slot] = ctx;
52
+ }
53
+ const ctx = _hdrCtx[slot];
54
+ return ctx ? ctx.measureText(text).width : 0;
55
+ }
56
+
57
+ /** The (i) is drawn for exactly this condition β€” `CustomerGrid.drawGridHeader` resolves the
58
+ * same one from the same field, so the reserve and the mark can never disagree. */
59
+ function hasInfoMark(field: Field): boolean {
60
+ return !!(field.note || field.description);
61
+ }
62
+
63
+ /** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
64
+ * rules are skipped: a rule that is not narrowing anything must not tint a column as though
65
+ * it were β€” that is the same "it looks like it is working" lie the engine refuses to tell.
66
+ *
67
+ * ⚠ Wave-20 item 2: the key a rule NAMES is not always the column it is ABOUT β€” a measure
68
+ * condition carries the MEASURE key. `ruleColumnKeys` is the one resolution (types.ts); it
69
+ * is what stopped a filtered-and-sorted measure column wearing the sort hue. */
70
+ function filteredKeys(
71
+ nodes: FilterNode[],
72
+ out: Set<string>,
73
+ measureCols: Map<string, string[]>
74
+ ): void {
75
+ for (const node of nodes ?? []) {
76
+ if (isFilterGroup(node)) {
77
+ filteredKeys(node.children, out, measureCols);
78
+ continue;
79
+ }
80
+ if (isRuleActive(node)) for (const key of ruleColumnKeys(node, measureCols)) out.add(key);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The column's theme override, or `undefined` β€” glide's fast path for "no override" is an absent
86
+ * object, so an empty `{}` would cost a ~35-key theme merge per column per frame.
87
+ *
88
+ * ⚠ Wave-14 item 1 (R11): a user-created field contributes no theme. Its yellow header wash is
89
+ * dead and its replacement is a painted DOT, not a background β€” so the only thing that can put a
90
+ * themeOverride on a column is being INVOLVED in a control.
91
+ *
92
+ * ⚠ Wave-15 R5: the returned object is one of the three SHARED `COLUMN_TONE_THEME` references
93
+ * (never a fresh object), which is what keeps glide's theme merge cache-friendly across frames.
94
+ *
95
+ * β›” Wave-29 R8 (2026-08-11): those three references now carry **HEADER keys only**. glide builds a
96
+ * header theme from the column override alone and a cell theme from column β†’ row β†’ cell
97
+ * (`data-grid-render.header.js:59`, `common/styles.js mergeAndRealizeTheme`), so the tone reaching
98
+ * a column theme is exactly how a sort used to repaint record bodies. With no `bgCell` in the
99
+ * table there is no longer a path from "this column is sorted" to a cell's background at all β€”
100
+ * which is a property of what `COLUMN_TONE_THEME` CONTAINS, not of anything this function does.
101
+ * A body wash belongs to `getRowThemeOverride` (status) or the cell itself (automation).
102
+ */
103
+ function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined {
104
+ return tone ? COLUMN_TONE_THEME[tone] : undefined;
105
+ }
106
+
107
+ /**
108
+ * Owner item 22 β€” key β†’ which control involves it, or absent.
109
+ *
110
+ * ⚠ ONE tone per column, and the precedence is a decision, not an accident: **filter > sort >
111
+ * group** (owner R6, wave 15). A filter changes WHICH ROWS you are looking at; when a column is
112
+ * doing two jobs the header names the most consequential one. Blending two tints would produce a
113
+ * third colour that matches no chip in the toolbar, which is worse than picking.
114
+ *
115
+ * ⚠ THE PRECEDENCE IS THE WRITE ORDER BELOW, and it reads backwards: later `set` calls WIN, so
116
+ * the least important control is written FIRST. It was `sort, group, filter` β€” i.e. filter >
117
+ * group > sort β€” until R6 named the order explicitly; getting this wrong is a one-line edit that
118
+ * changes a colour on screen and nothing else, so it is stated here rather than left to be
119
+ * re-derived from the sequence.
120
+ */
121
+ /* wave17 GRID β€” item 2 / owner R5. EXPORTED, and only since the row band died.
122
+ `activeControlTone` used to sit below this function and restate the same precedence for the
123
+ whole-table band; the band was its only consumer, so R5 took both. That left the surviving
124
+ precedence β€” the one that still paints β€” asserted by nothing, because `verify_icons` was
125
+ testing the twin. Exported so the gate can reach the function that is actually on screen. */
126
+ export function columnTones(
127
+ config: ViewConfig,
128
+ /* wave20 item 2 β€” the FIELDS, because a measure condition names its measure and only the
129
+ field list can say which column displays it. Defaulted so the signature stays callable
130
+ with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
131
+ legitimately has no measure columns to resolve. */
132
+ fields: Field[] = []
133
+ ): Map<string, ControlTone> {
134
+ const out = new Map<string, ControlTone>();
135
+ if (config.groupBy) out.set(config.groupBy, "group");
136
+ for (const s of config.sorts ?? []) out.set(s.colId, "sort");
137
+ const filtered = new Set<string>();
138
+ filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
139
+ for (const key of filtered) out.set(key, "filter");
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * ⭐ WAVE 30 (owner item 2 scouting) β€” THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
145
+ *
146
+ * This read `field.default !== false || field.source === "overlay"`. The second arm was written
147
+ * when `overlay` meant "a column a user added here", and those columns have no `default` key at
148
+ * all β€” so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
149
+ * stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
150
+ * one: the exception quietly became the rule and swallowed the first arm entirely.
151
+ *
152
+ * β›” WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
153
+ * `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
154
+ * opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
155
+ * `shownCount === fields.length` β€” so the control that says "some columns are hidden" says the
156
+ * opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
157
+ *
158
+ * ⚠ DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
159
+ * believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
160
+ * true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
161
+ * `ut_*` column can never arrive carrying `default: false` by accident β€” only a shipped field
162
+ * CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
163
+ *
164
+ * ⚠ THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
165
+ * `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
166
+ * records" system view the client pins as its landing default. Fixing one side alone changes
167
+ * nothing a user sees β€” see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
168
+ * languages).
169
+ */
170
+ function isDefaultVisible(field: Field): boolean {
171
+ if (field.default === false) return false;
172
+ /**
173
+ * ⭐⭐ OWNER, 2026-08-23 β€” A NEW VIEW SHOWS THE DATABASE'S OWN COLUMNS AND NOTHING ELSE.
174
+ *
175
+ * Owner: *"when any new View is created only shareable pre-set data should show. Shared to me
176
+ * or Shared to everyone should be auto hidden."*
177
+ *
178
+ * β›” `custom || shared` IS THE WHOLE PREDICATE, and it is chosen because it is exactly the
179
+ * membership test `FieldsHidePanel` already uses to draw its two SHARED sections:
180
+ * "Shared with me" (field.custom || field.shared) && fieldEditMode === "users"
181
+ * "Shared with everyone" (field.custom || field.shared) && fieldEditMode === "collaborative"
182
+ * Both sections are subsets of `custom || shared`, so hiding on that flag hides both by
183
+ * construction rather than by keeping a second list of section names in step with the panel.
184
+ *
185
+ * ⚠ IT ALSO HIDES THE UNTITLED SECTION'S CREATED COLUMNS, and that is deliberate rather than
186
+ * over-reach. A route order carries `shared: true` with `edit: personal`, so it is filed under
187
+ * no heading at all β€” and 52 of them landed on the customer grid the same day this was asked
188
+ * for. The owner's first sentence is the rule ("ONLY pre-set data shows"); the second names the
189
+ * two headings, it does not bound the set.
190
+ *
191
+ * ⚠ MEASURED ON THE LIVE CUSTOMER GRID before it was written: of 105 fields, 77 opened SHOWN.
192
+ * 52 route columns, 14 collaborative `custom_*`, 3 `measure_*`, one legacy route and 7 plain
193
+ * Odoo columns. After this, the 7 remain β€” which is precisely what
194
+ * `aios_grid_fields.json` declares as `default: true`, i.e. the contract's own answer before
195
+ * anybody's columns piled on top of it.
196
+ *
197
+ * β›”β›” A STORED VIEW IS UNTOUCHED. `normalizeConfig` spreads the saved `config` OVER this
198
+ * base, so a view that already recorded its own `visible` keeps it. What moves is a view born
199
+ * without one: the "All records" system view, a new view, a cohort view.
200
+ */
201
+ if (field.custom === true || field.shared === true) return false;
202
+ return true;
203
+ }
204
+
205
+ export function defaultViewConfig(fields: Field[]): ViewConfig {
206
+ const shown = fields.filter(isDefaultVisible).map((field) => field.key);
207
+ const hidden = fields.filter((field) => !isDefaultVisible(field)).map((field) => field.key);
208
+ return {
209
+ filters: [],
210
+ filterConj: "and",
211
+ sorts: [],
212
+ groupBy: null,
213
+ colorBy: null,
214
+ rowHeightMode: "short",
215
+ order: [...shown, ...hidden],
216
+ visible: shown,
217
+ widths: {},
218
+ memberPids: [],
219
+ };
220
+ }
221
+
222
+ function reconcileOrder(order: string[], fields: Field[]): string[] {
223
+ const valid = new Set(fields.map((field) => field.key));
224
+ const kept = order.filter((key, index) => valid.has(key) && order.indexOf(key) === index);
225
+ for (const field of fields) if (!kept.includes(field.key)) kept.push(field.key);
226
+ const locked = fields.find((field) => field.pinned)?.key ?? fields[0]?.key;
227
+ if (locked && kept[0] !== locked) {
228
+ const without = kept.filter((key) => key !== locked);
229
+ return [locked, ...without];
230
+ }
231
+ return kept;
232
+ }
233
+
234
+ function reconcileVisible(visible: string[], fields: Field[], lockedKey: string): Set<string> {
235
+ const valid = new Set(fields.map((field) => field.key));
236
+ const out = new Set(visible.filter((key) => valid.has(key)));
237
+ if (out.size === 0) {
238
+ for (const field of fields) if (isDefaultVisible(field)) out.add(field.key);
239
+ }
240
+ if (lockedKey) out.add(lockedKey);
241
+ return out;
242
+ }
243
+
244
+ export interface GridColumnsApi {
245
+ visibleCols: GridColumn[];
246
+ fieldByKey: Map<string, Field>;
247
+ order: string[];
248
+ visible: Set<string>;
249
+ widths: Record<string, number>;
250
+ lockedKey: string;
251
+ onColumnResize: (col: GridColumn, newSize: number) => void;
252
+ onColumnMoved: (from: number, to: number) => void;
253
+ onColumnProposeMove: (from: number, to: number) => boolean;
254
+ setColumnVisible: (key: string, show: boolean) => void;
255
+ insertColumn: (key: string, anchorKey: string | null, side: "left" | "right" | "end") => void;
256
+ }
257
+
258
+ /**
259
+ * Controlled column adapter. ViewConfig is the durable source of truth, so
260
+ * choosing a saved view is one atomic state change instead of a chain of hook
261
+ * setters that can flash or overwrite one another.
262
+ */
263
+ export function useGridColumns(
264
+ fields: Field[],
265
+ config: ViewConfig,
266
+ onConfig: (next: ViewConfig) => void
267
+ ): GridColumnsApi {
268
+ const fieldByKey = useMemo(
269
+ () => new Map(fields.map((field) => [field.key, field])),
270
+ [fields]
271
+ );
272
+ const lockedKey = useMemo(
273
+ () => fields.find((field) => field.pinned)?.key ?? fields[0]?.key ?? "",
274
+ [fields]
275
+ );
276
+ const order = useMemo(() => reconcileOrder(config.order, fields), [config.order, fields]);
277
+ const visible = useMemo(
278
+ () => reconcileVisible(config.visible, fields, lockedKey),
279
+ [config.visible, fields, lockedKey]
280
+ );
281
+
282
+ // Item 22 β€” recomputed only when the three controls move, not on every width drag.
283
+ // Wave-20 item 2 adds `fields`: a measure condition can only be resolved to the column that
284
+ // displays it, and a new measure column must re-tint without waiting for a control to move.
285
+ const tones = useMemo(
286
+ () => columnTones(config, fields),
287
+ [config.filters, config.sorts, config.groupBy, fields] // eslint-disable-line react-hooks/exhaustive-deps
288
+ );
289
+
290
+ const visibleCols = useMemo<GridColumn[]>(
291
+ () =>
292
+ order
293
+ .filter((key) => visible.has(key))
294
+ .map((key) => {
295
+ const field = fieldByKey.get(key)!;
296
+ const width = config.widths[key] ?? DEFAULT_WIDTH;
297
+ // Wave-14 item 2 β€” an INVOLVED column's header paints at 700, so it must be MEASURED
298
+ // at 700. `tones` is already a dep of this memo, so this costs nothing.
299
+ const involved = tones.has(key);
300
+ // R11 β€” the marks this header carries, in the strip's canonical order. Both this
301
+ // reserve and `CustomerGrid.drawGridHeader`'s painting read `headerMarkSizes`, so a
302
+ // column can never reserve room for one mark and then be given two.
303
+ const marks = headerMarkSizes(hasInfoMark(field), field.source === "overlay");
304
+ return {
305
+ id: key,
306
+ // Owner item 16 β€” ELLIPSISED so the title can never run under the (i). Glide
307
+ // truncates nothing: `drawHeaderInner` calls `fillText(c.title, …)` with no clip
308
+ // and no max width, so on a narrow column the name simply painted through the
309
+ // mark. Shortening the title glide is given is the whole fix β€” the alternative,
310
+ // clipping inside `drawHeader`, cuts mid-glyph with no ellipsis to say it did.
311
+ // The FULL name stays reachable: CustomerGrid's header tip leads with it whenever
312
+ // this returns something shorter than `field.label`.
313
+ title: fitHeaderTitle(
314
+ field.label,
315
+ headerLabelSpace(width, marks),
316
+ (text) => measureHeaderText(text, involved)
317
+ ),
318
+ width,
319
+ hasMenu: true,
320
+ menuIcon: "dots",
321
+ // Wave-8 I20 β€” the field-TYPE mark leads every header, so the column says what
322
+ // KIND of thing it holds before you read a single cell (same geometry as the
323
+ // Fields panel's rows β€” icons.tsx).
324
+ icon: typeIconName(field.type),
325
+ // Wave-9 I3 β€” the (i) is NO LONGER `overlayIcon`. Glide draws an overlay at a
326
+ // hard-coded offset from the TYPE mark (drawHeaderInner: `drawX + 9`), i.e.
327
+ // pinned to the far LEFT of the header, and no prop moves it; the owner asked
328
+ // for it right-aligned and centred with the field name. It is drawn instead by
329
+ // CustomerGrid's `drawHeader` callback, which resolves the same
330
+ // `field.note || field.description` condition from the field itself.
331
+ // The hover text is still CustomerGrid's floating tip (pointer-events: none β€”
332
+ // a tooltip must never swallow the next click, [[ui-invisible-to-assertions]]).
333
+ // Wave-14 R4 β€” being INVOLVED in a filter/sort/group is the only thing that tints a
334
+ // column now, and all three tint it the same faint warm grey; the hue that says
335
+ // WHICH control lives on the toolbar chip. User-created ownership moved out of the
336
+ // theme entirely and is the dot drawn in the mark strip (R11).
337
+ themeOverride: columnTheme(tones.get(key)),
338
+ };
339
+ }),
340
+ [order, visible, config.widths, fieldByKey, tones]
341
+ );
342
+
343
+ const onColumnResize = useCallback(
344
+ (column: GridColumn, newSize: number) => {
345
+ if (!column.id) return;
346
+ onConfig({
347
+ ...config,
348
+ widths: { ...config.widths, [column.id]: Math.round(newSize) },
349
+ });
350
+ },
351
+ [config, onConfig]
352
+ );
353
+
354
+ const onColumnMoved = useCallback(
355
+ (from: number, to: number) => {
356
+ if (from === 0 || to === 0) return;
357
+ const visibleKeys = order.filter((key) => visible.has(key));
358
+ if (
359
+ from < 0 ||
360
+ from >= visibleKeys.length ||
361
+ to < 0 ||
362
+ to >= visibleKeys.length
363
+ )
364
+ return;
365
+ const movedKeys = visibleKeys.slice();
366
+ const [moved] = movedKeys.splice(from, 1);
367
+ movedKeys.splice(to, 0, moved);
368
+ let index = 0;
369
+ const nextOrder = order.map((key) =>
370
+ visible.has(key) ? movedKeys[index++] : key
371
+ );
372
+ onConfig({ ...config, order: nextOrder });
373
+ },
374
+ [config, onConfig, order, visible]
375
+ );
376
+
377
+ const onColumnProposeMove = useCallback(
378
+ (from: number, to: number) => from !== 0 && to !== 0,
379
+ []
380
+ );
381
+
382
+ const setColumnVisible = useCallback(
383
+ (key: string, show: boolean) => {
384
+ if (!show && key === lockedKey) return;
385
+ const next = new Set(visible);
386
+ if (show) next.add(key);
387
+ else next.delete(key);
388
+ onConfig({ ...config, visible: [...next] });
389
+ },
390
+ [config, lockedKey, onConfig, visible]
391
+ );
392
+
393
+ const insertColumn = useCallback(
394
+ (key: string, anchorKey: string | null, side: "left" | "right" | "end") => {
395
+ const nextOrder = order.filter((item) => item !== key);
396
+ if (side === "end" || !anchorKey) {
397
+ nextOrder.push(key);
398
+ } else {
399
+ const anchor = Math.max(0, nextOrder.indexOf(anchorKey));
400
+ nextOrder.splice(anchor + (side === "right" ? 1 : 0), 0, key);
401
+ }
402
+ const nextVisible = new Set(visible);
403
+ nextVisible.add(key);
404
+ onConfig({ ...config, order: nextOrder, visible: [...nextVisible] });
405
+ },
406
+ [config, onConfig, order, visible]
407
+ );
408
+
409
+ return {
410
+ visibleCols,
411
+ fieldByKey,
412
+ order,
413
+ visible,
414
+ widths: config.widths,
415
+ lockedKey,
416
+ onColumnResize,
417
+ onColumnMoved,
418
+ onColumnProposeMove,
419
+ setColumnVisible,
420
+ insertColumn,
421
+ };
422
+ }