@ai-matrx/design-system 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/data-table/copy-helpers.d.cts +2 -2
  3. package/dist/data-table/copy-helpers.d.ts +2 -2
  4. package/dist/data-table/filter-engine.d.cts +2 -2
  5. package/dist/data-table/filter-engine.d.ts +2 -2
  6. package/dist/data-table/host.d.cts +2 -2
  7. package/dist/data-table/host.d.ts +2 -2
  8. package/dist/data-table/index.cjs +269 -5
  9. package/dist/data-table/index.cjs.map +1 -1
  10. package/dist/data-table/index.d.cts +3 -3
  11. package/dist/data-table/index.d.ts +3 -3
  12. package/dist/data-table/index.js +269 -5
  13. package/dist/data-table/index.js.map +1 -1
  14. package/dist/data-table/infer-filter.d.cts +2 -2
  15. package/dist/data-table/infer-filter.d.ts +2 -2
  16. package/dist/data-table/layered-filters.d.cts +2 -2
  17. package/dist/data-table/layered-filters.d.ts +2 -2
  18. package/dist/data-table/query-control.d.cts +2 -2
  19. package/dist/data-table/query-control.d.ts +2 -2
  20. package/dist/data-table/types.d.cts +2 -2
  21. package/dist/data-table/types.d.ts +2 -2
  22. package/dist/data-table/url-state.d.cts +2 -2
  23. package/dist/data-table/url-state.d.ts +2 -2
  24. package/dist/data-table/validation.d.cts +1 -1
  25. package/dist/data-table/validation.d.ts +1 -1
  26. package/dist/data-table/xlsx.d.cts +2 -2
  27. package/dist/data-table/xlsx.d.ts +2 -2
  28. package/dist/field-formats/index.cjs +269 -5
  29. package/dist/field-formats/index.cjs.map +1 -1
  30. package/dist/field-formats/index.d.cts +2 -2
  31. package/dist/field-formats/index.d.ts +2 -2
  32. package/dist/field-formats/index.js +269 -5
  33. package/dist/field-formats/index.js.map +1 -1
  34. package/dist/formulas/index.cjs +38 -5
  35. package/dist/formulas/index.cjs.map +1 -1
  36. package/dist/formulas/index.d.cts +39 -2
  37. package/dist/formulas/index.d.ts +39 -2
  38. package/dist/formulas/index.js +38 -5
  39. package/dist/formulas/index.js.map +1 -1
  40. package/dist/{types-oVyZbfwn.d.cts → types-DNkbHRVK.d.cts} +1 -1
  41. package/dist/{types-Z32pxexx.d.ts → types-Dx5hJgCi.d.ts} +1 -1
  42. package/dist/{types-CJWU1Gae.d.cts → types-JnZD8wM0.d.cts} +27 -2
  43. package/dist/{types-CJWU1Gae.d.ts → types-JnZD8wM0.d.ts} +27 -2
  44. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.41.0 (2026-09-25)
4
+
5
+ ### Lane MERGE-STEP-2 (data-tables-grid-overhaul, 2026-09-25)
6
+
7
+ The app's `/data` grid kept forks of `field-formats` and `formulas` after they moved here, and
8
+ the forks grew. Their newer behaviour moves into the package so the app can delete its copies.
9
+
10
+ * **`@ai-matrx/design-system/field-formats` gains nine formats** the app had added since the move:
11
+ `address` (opens in maps), `progress` (a percentage drawn as a bar), `time` (time of day, with
12
+ `options.timeSeconds`), `autonumber` (database-assigned, `prefix`), the system columns
13
+ `created_time` / `modified_time`, `attachment` (file ids from Files; `base: "array"`, also
14
+ accepts `json` but NOT `string`, so on a text column it is offered under "Other kinds" and
15
+ retypes the column instead of storing JSON in a text cell), `person` (an organization member,
16
+ choice-shaped) and `relation` (a record of another table: `relation_target`, `relation_max`,
17
+ `on_delete`, `display`; a value that did not resolve to words is a mismatch, never a bare id).
18
+ `FieldEditorKind` gains `"time"` and `"attachment"`; `FieldFormatId` and `FieldFormatOptions`
19
+ grow to match. Additive.
20
+ * **`@ai-matrx/design-system/formulas` gains the computed-column helpers:** `systemColumnKindOf`,
21
+ `SystemColumnKind`, `isAutonumberColumn`, `isComputedColumn` (THE check every write path uses to
22
+ refuse a computed cell). `withComputedColumns` fills `created_time` / `modified_time` columns from
23
+ the row's own `created_at` / `updated_at`, counts system and autonumber columns in
24
+ `formulaFieldNames`, and takes an optional third argument `displayValueOf(fieldName, raw)` so a
25
+ formula sees a relation cell's words rather than its id. Omitted, behaviour is unchanged.
26
+ * Tests moved with the behaviour: `field-formats/{format,address-progress-format,attachment-format,
27
+ time-format,identifier-formats}.test.ts`, `formulas/computed-columns.test.ts`.
28
+
29
+ **Consumer action:** matrx-frontend deletes its `lib/field-formats/{registry,types,format}.ts` and
30
+ `features/data-tables/{formulas,grid-selection,grid-clipboard}.ts` forks and imports these subpaths.
31
+ Other consumers: none.
32
+
3
33
  ## 0.40.0 (2026-09-25 — version and notes set by PUBLISHER-5)
4
34
 
5
35
  ### Lane TABLE-PAGE-CHROME (2026-09-25)
@@ -1,11 +1,11 @@
1
1
  import { AgentPayloadInput } from './copy-types.cjs';
2
- import { g as MatrxDataTableCopyConfig, M as MatrxColumnDef } from '../types-oVyZbfwn.cjs';
2
+ import { g as MatrxDataTableCopyConfig, M as MatrxColumnDef } from '../types-DNkbHRVK.cjs';
3
3
  import '../content-transfer.cjs';
4
4
  import 'react';
5
5
  import '@ai-matrx/kit/content-transfer';
6
6
  import '../content-transfer/icon.cjs';
7
7
  import './table-style.cjs';
8
- import '../types-CJWU1Gae.cjs';
8
+ import '../types-JnZD8wM0.cjs';
9
9
  import './grouping.cjs';
10
10
  import './fill.cjs';
11
11
  import './facets.cjs';
@@ -1,11 +1,11 @@
1
1
  import { AgentPayloadInput } from './copy-types.js';
2
- import { g as MatrxDataTableCopyConfig, M as MatrxColumnDef } from '../types-Z32pxexx.js';
2
+ import { g as MatrxDataTableCopyConfig, M as MatrxColumnDef } from '../types-Dx5hJgCi.js';
3
3
  import '../content-transfer.js';
4
4
  import 'react';
5
5
  import '@ai-matrx/kit/content-transfer';
6
6
  import '../content-transfer/icon.js';
7
7
  import './table-style.js';
8
- import '../types-CJWU1Gae.js';
8
+ import '../types-JnZD8wM0.js';
9
9
  import './grouping.js';
10
10
  import './fill.js';
11
11
  import './facets.js';
@@ -1,11 +1,11 @@
1
- import { M as MatrxColumnDef, C as ColumnFiltersState, S as SortState, L as LayeredFilterRule, T as TableSearchMatchMode, a as ColumnFilterValue } from '../types-oVyZbfwn.cjs';
1
+ import { M as MatrxColumnDef, C as ColumnFiltersState, S as SortState, L as LayeredFilterRule, T as TableSearchMatchMode, a as ColumnFilterValue } from '../types-DNkbHRVK.cjs';
2
2
  import '../content-transfer.cjs';
3
3
  import 'react';
4
4
  import '@ai-matrx/kit/content-transfer';
5
5
  import '../content-transfer/icon.cjs';
6
6
  import './copy-types.cjs';
7
7
  import './table-style.cjs';
8
- import '../types-CJWU1Gae.cjs';
8
+ import '../types-JnZD8wM0.cjs';
9
9
  import './grouping.cjs';
10
10
  import './fill.cjs';
11
11
  import './facets.cjs';
@@ -1,11 +1,11 @@
1
- import { M as MatrxColumnDef, C as ColumnFiltersState, S as SortState, L as LayeredFilterRule, T as TableSearchMatchMode, a as ColumnFilterValue } from '../types-Z32pxexx.js';
1
+ import { M as MatrxColumnDef, C as ColumnFiltersState, S as SortState, L as LayeredFilterRule, T as TableSearchMatchMode, a as ColumnFilterValue } from '../types-Dx5hJgCi.js';
2
2
  import '../content-transfer.js';
3
3
  import 'react';
4
4
  import '@ai-matrx/kit/content-transfer';
5
5
  import '../content-transfer/icon.js';
6
6
  import './copy-types.js';
7
7
  import './table-style.js';
8
- import '../types-CJWU1Gae.js';
8
+ import '../types-JnZD8wM0.js';
9
9
  import './grouping.js';
10
10
  import './fill.js';
11
11
  import './facets.js';
@@ -2,13 +2,13 @@ import * as React from 'react';
2
2
  import { ComponentType, ReactNode, MouseEventHandler } from 'react';
3
3
  import { CopyControlProps } from './copy-types.cjs';
4
4
  export { AgentPayloadInput, AiCustomSource, AiVariant, CopyExportConfig } from './copy-types.cjs';
5
- import { b as TableSavedViewsProps, c as MatrxDataTableDensity, d as MatrxDataTableRecordControls } from '../types-oVyZbfwn.cjs';
5
+ import { b as TableSavedViewsProps, c as MatrxDataTableDensity, d as MatrxDataTableRecordControls } from '../types-DNkbHRVK.cjs';
6
6
  import { MatrxTableMenuTarget, MatrxTableMenuPayload } from './menu-targets.cjs';
7
7
  import '../content-transfer.cjs';
8
8
  import '@ai-matrx/kit/content-transfer';
9
9
  import '../content-transfer/icon.cjs';
10
10
  import './table-style.cjs';
11
- import '../types-CJWU1Gae.cjs';
11
+ import '../types-JnZD8wM0.cjs';
12
12
  import './grouping.cjs';
13
13
  import './fill.cjs';
14
14
  import './facets.cjs';
@@ -2,13 +2,13 @@ import * as React from 'react';
2
2
  import { ComponentType, ReactNode, MouseEventHandler } from 'react';
3
3
  import { CopyControlProps } from './copy-types.js';
4
4
  export { AgentPayloadInput, AiCustomSource, AiVariant, CopyExportConfig } from './copy-types.js';
5
- import { b as TableSavedViewsProps, c as MatrxDataTableDensity, d as MatrxDataTableRecordControls } from '../types-Z32pxexx.js';
5
+ import { b as TableSavedViewsProps, c as MatrxDataTableDensity, d as MatrxDataTableRecordControls } from '../types-Dx5hJgCi.js';
6
6
  import { MatrxTableMenuTarget, MatrxTableMenuPayload } from './menu-targets.js';
7
7
  import '../content-transfer.js';
8
8
  import '@ai-matrx/kit/content-transfer';
9
9
  import '../content-transfer/icon.js';
10
10
  import './table-style.js';
11
- import '../types-CJWU1Gae.js';
11
+ import '../types-JnZD8wM0.js';
12
12
  import './grouping.js';
13
13
  import './fill.js';
14
14
  import './facets.js';
@@ -10253,6 +10253,35 @@ function toText(value) {
10253
10253
  return String(value);
10254
10254
  return null;
10255
10255
  }
10256
+ function parseTimeOfDay(value) {
10257
+ if (value instanceof Date) {
10258
+ return { h: value.getHours(), m: value.getMinutes(), s: value.getSeconds() };
10259
+ }
10260
+ if (typeof value !== "string") return null;
10261
+ const text = value.trim();
10262
+ if (!text) return null;
10263
+ const m = /^(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*([aApP]\.?[mM]\.?)?$/.exec(text);
10264
+ if (m) {
10265
+ let h = Number(m[1]);
10266
+ const min = m[2] ? Number(m[2]) : 0;
10267
+ const sec = m[3] ? Number(m[3]) : 0;
10268
+ const ampm = m[4]?.toLowerCase().replace(/\./g, "");
10269
+ if (ampm) {
10270
+ if (h < 1 || h > 12) return null;
10271
+ if (ampm === "pm" && h !== 12) h += 12;
10272
+ if (ampm === "am" && h === 12) h = 0;
10273
+ }
10274
+ if (h > 23 || min > 59 || sec > 59) return null;
10275
+ return { h, m: min, s: sec };
10276
+ }
10277
+ if (/\d{4}-\d{2}-\d{2}T/.test(text)) {
10278
+ const d = new Date(text);
10279
+ if (!Number.isNaN(d.getTime())) {
10280
+ return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
10281
+ }
10282
+ }
10283
+ return null;
10284
+ }
10256
10285
  var BARE_CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]00:00(?::00(?:\.0+)?)?)?$/;
10257
10286
  function toDate(value) {
10258
10287
  if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
@@ -10373,6 +10402,23 @@ var DEFS = [
10373
10402
  },
10374
10403
  parse: (raw) => raw === "" || raw == null ? null : String(raw).trim()
10375
10404
  },
10405
+ {
10406
+ // A postal address. Free text by design (addresses have no one shape
10407
+ // worldwide); the cell opens it in maps, which is the thing people do
10408
+ // with an address in a table.
10409
+ id: "address",
10410
+ label: "Address",
10411
+ description: "Street address \u2014 click to open in maps",
10412
+ group: "Text",
10413
+ base: "string",
10414
+ editor: "text",
10415
+ rich: true,
10416
+ format: (v) => {
10417
+ const t = toText(v)?.trim();
10418
+ return t ? t : null;
10419
+ },
10420
+ parse: (raw) => raw === "" || raw == null ? null : String(raw)
10421
+ },
10376
10422
  {
10377
10423
  id: "url",
10378
10424
  label: "Link",
@@ -10506,6 +10552,26 @@ var DEFS = [
10506
10552
  },
10507
10553
  parse: (raw) => toNumber(raw)
10508
10554
  },
10555
+ {
10556
+ // A percentage DRAWN as a bar — task completion, quota used, a score out of
10557
+ // 100. Same storage and scale rule as `percent`; only the rendering differs.
10558
+ id: "progress",
10559
+ label: "Progress bar",
10560
+ description: "A percentage shown as a bar",
10561
+ group: "Numbers",
10562
+ base: "number",
10563
+ alsoAccepts: ["integer"],
10564
+ editor: "number",
10565
+ rich: true,
10566
+ optionKeys: ["percentScale"],
10567
+ format: (v, o) => {
10568
+ const n2 = toNumber(v);
10569
+ if (n2 === null) return null;
10570
+ const shown = o.percentScale === "fraction" ? n2 * 100 : n2;
10571
+ return `${groupedNumber(shown, { precision: 0 })}%`;
10572
+ },
10573
+ parse: (raw) => toNumber(raw)
10574
+ },
10509
10575
  {
10510
10576
  id: "duration",
10511
10577
  label: "Duration",
@@ -10574,13 +10640,16 @@ var DEFS = [
10574
10640
  alsoAccepts: ["number"],
10575
10641
  editor: "number",
10576
10642
  numericAlign: true,
10643
+ // The name says BYTES on purpose: this cell holds a genuine byte length,
10644
+ // never a character or item count, and `formatFileSize` is only honest
10645
+ // about a quantity that is actually bytes.
10577
10646
  format: (v) => {
10578
- const n2 = toNumber(v);
10579
- return n2 === null ? null : (0, import_format3.formatFileSize)(n2);
10647
+ const byteLength = toNumber(v);
10648
+ return byteLength === null ? null : (0, import_format3.formatFileSize)(byteLength);
10580
10649
  },
10581
10650
  parse: (raw) => {
10582
- const n2 = toNumber(raw);
10583
- return n2 === null ? null : Math.trunc(n2);
10651
+ const byteLength = toNumber(raw);
10652
+ return byteLength === null ? null : Math.trunc(byteLength);
10584
10653
  }
10585
10654
  },
10586
10655
  // ── Choice ────────────────────────────────────────────────────────────────
@@ -10641,6 +10710,87 @@ var DEFS = [
10641
10710
  },
10642
10711
  parse: (raw) => raw === "" || raw == null ? null : String(raw)
10643
10712
  },
10713
+ {
10714
+ // A TIME OF DAY with no date: opening hours, a shift start, a meeting slot.
10715
+ // Stored as the string the <input type="time"> gives ("HH:MM" or
10716
+ // "HH:MM:SS"), shown in the viewer's locale ("2:30 PM" / "14:30"). The
10717
+ // storage type stays `string`; sorting is lexical on the 24-hour form,
10718
+ // which is chronological — the one reason the stored form is 24-hour.
10719
+ id: "time",
10720
+ label: "Time",
10721
+ description: "Time of day, without a date",
10722
+ group: "Dates",
10723
+ base: "string",
10724
+ editor: "time",
10725
+ optionKeys: ["timeSeconds"],
10726
+ format: (v, o) => {
10727
+ const parts = parseTimeOfDay(v);
10728
+ if (!parts) return null;
10729
+ const d = new Date(2e3, 0, 1, parts.h, parts.m, parts.s);
10730
+ return d.toLocaleTimeString(void 0, {
10731
+ hour: "numeric",
10732
+ minute: "2-digit",
10733
+ ...o.timeSeconds ? { second: "2-digit" } : {}
10734
+ });
10735
+ },
10736
+ // Normalise to the 24-hour "HH:MM[:SS]" form; a value that is not a time
10737
+ // is handed back unchanged so the validation layer can refuse it by name.
10738
+ parse: (raw) => {
10739
+ if (raw == null || raw === "") return null;
10740
+ const parts = parseTimeOfDay(raw);
10741
+ if (!parts) return raw;
10742
+ const two = (n2) => String(n2).padStart(2, "0");
10743
+ const fields = parts.s > 0 ? [parts.h, parts.m, parts.s] : [parts.h, parts.m];
10744
+ return fields.map(two).join(":");
10745
+ }
10746
+ },
10747
+ {
10748
+ // Assigned BY THE DATABASE when the row is inserted (trigger
10749
+ // `_udt_autonumber`), so it is the same number whichever door made the row
10750
+ // and never shifts when another row is deleted. Stored in the cell as an
10751
+ // integer; every editor refuses it. `prefix` turns 42 into "INV-42".
10752
+ id: "autonumber",
10753
+ label: "Autonumber",
10754
+ description: "A number the table assigns to each new row \u2014 never typed",
10755
+ group: "Numbers",
10756
+ base: "integer",
10757
+ alsoAccepts: ["number"],
10758
+ editor: "computed",
10759
+ numericAlign: true,
10760
+ optionKeys: ["prefix"],
10761
+ format: (v, o) => {
10762
+ const n2 = toNumber(v);
10763
+ if (n2 === null) return null;
10764
+ return `${o.prefix ?? ""}${Math.trunc(n2)}`;
10765
+ },
10766
+ parse: () => null
10767
+ },
10768
+ // ── System columns ────────────────────────────────────────────────────────
10769
+ // Filled from the ROW'S OWN RECORD on read (`withComputedColumns` in
10770
+ // `@ai-matrx/design-system/formulas`), never typed and never stored in the
10771
+ // cell. `editor: "computed"` makes every editor refuse them.
10772
+ {
10773
+ id: "created_time",
10774
+ label: "Created time",
10775
+ description: "When the row was added \u2014 filled in automatically",
10776
+ group: "Dates",
10777
+ base: "string",
10778
+ editor: "computed",
10779
+ optionKeys: ["dateStyle"],
10780
+ format: (v, o) => FIELD_FORMATS.datetime.format(v, o),
10781
+ parse: () => null
10782
+ },
10783
+ {
10784
+ id: "modified_time",
10785
+ label: "Last modified time",
10786
+ description: "When the row last changed \u2014 filled in automatically",
10787
+ group: "Dates",
10788
+ base: "string",
10789
+ editor: "computed",
10790
+ optionKeys: ["dateStyle"],
10791
+ format: (v, o) => FIELD_FORMATS.datetime.format(v, o),
10792
+ parse: () => null
10793
+ },
10644
10794
  {
10645
10795
  id: "relative_time",
10646
10796
  label: "Relative time",
@@ -10697,6 +10847,44 @@ var DEFS = [
10697
10847
  return toList(raw) ?? raw;
10698
10848
  }
10699
10849
  },
10850
+ {
10851
+ // FILES ATTACHED TO THE ROW. The cell stores the files feature's file ids
10852
+ // (`files.files.id`), never a URL or a name: a rename or a move in Files
10853
+ // changes nothing here, and access is the file's own (RLS) — a reader who
10854
+ // may see the row but not the file gets the chip's honest "no access", not
10855
+ // a broken link. Drawn by `AttachmentChips`, edited by `AttachmentInput`
10856
+ // through the ONE file picker.
10857
+ id: "attachment",
10858
+ label: "Attachments",
10859
+ description: "Files from your Files, shown as chips",
10860
+ group: "Structured",
10861
+ base: "array",
10862
+ // Not `string`: on a text column it must appear under "Other kinds" so
10863
+ // picking it retypes the column to a list, rather than storing a JSON
10864
+ // string of ids in a text cell.
10865
+ alsoAccepts: ["json"],
10866
+ editor: "attachment",
10867
+ rich: true,
10868
+ format: (v) => {
10869
+ const list = toList(v);
10870
+ if (list === null) return null;
10871
+ const n2 = list.filter((i) => String(i).trim()).length;
10872
+ return n2 === 0 ? "" : n2 === 1 ? "1 file" : `${n2} files`;
10873
+ },
10874
+ parse: (raw) => {
10875
+ if (raw == null || raw === "") return null;
10876
+ if (Array.isArray(raw)) return raw.map((i) => String(i).trim()).filter(Boolean);
10877
+ const text = String(raw).trim();
10878
+ if (text.startsWith("[")) {
10879
+ try {
10880
+ const parsed = JSON.parse(text);
10881
+ if (Array.isArray(parsed)) return parsed.map((i) => String(i).trim()).filter(Boolean);
10882
+ } catch {
10883
+ }
10884
+ }
10885
+ return text.split(",").map((s) => s.trim()).filter(Boolean);
10886
+ }
10887
+ },
10700
10888
  {
10701
10889
  id: "tags",
10702
10890
  label: "Tags",
@@ -10760,6 +10948,82 @@ var DEFS = [
10760
10948
  return trimmed === "" ? null : trimmed;
10761
10949
  }
10762
10950
  },
10951
+ {
10952
+ // A PERSON: one member of the organization the table belongs to, stored as
10953
+ // the user id. It is a choice column whose options are the members —
10954
+ // supplied at runtime by whoever renders it (features/data-tables/
10955
+ // person-choices.tsx), the same door a structured-list-bound column uses —
10956
+ // so the cell picker, the chip, filters, color-by and the agent scope are
10957
+ // all the choice system's, with no second implementation.
10958
+ id: "person",
10959
+ label: "Person",
10960
+ description: "Someone in your organization \u2014 pick from its members",
10961
+ group: "Choice",
10962
+ base: "string",
10963
+ editor: "select",
10964
+ rich: true,
10965
+ format: (v, o) => FIELD_FORMATS.choice.format(v, o),
10966
+ parse: (raw) => FIELD_FORMATS.choice.parse(raw, {})
10967
+ },
10968
+ {
10969
+ // A RECORD OF ANOTHER TABLE. The cell stores that record's ID; the screen
10970
+ // shows its WORDS. `person` is the precedent and the template — it has
10971
+ // stored a user id and displayed a name through `choiceMap` since
10972
+ // 2026-09-21 — so `relation` is a member of CHOICE_FORMAT_IDS and every
10973
+ // one of the ten readers that print a cell resolves it through the path
10974
+ // they already use for `person`. There is no eleventh place to remember.
10975
+ //
10976
+ // THREE STATES, THREE RENDERINGS, NO FOURTH (OLD-TABLES-CUTOVER §3.3):
10977
+ // resolved the target's words
10978
+ // unresolvable the amber fallback WITH the id, as an identifier — never
10979
+ // a blank, and never the bare uuid as if it were text, so a
10980
+ // person can act on it and support can trace it
10981
+ // withheld platform.relation_withheld_label(), with NO id — a cell
10982
+ // the reader may not see never leaks the identifier through
10983
+ // the gap between the two stores' refusals
10984
+ //
10985
+ // `format` therefore returns null (the fallback law's "cannot interpret")
10986
+ // for anything the resolver did not turn into words, and the renderer is
10987
+ // what distinguishes unresolvable from withheld. It never prints a bare id.
10988
+ id: "relation",
10989
+ label: "Relation",
10990
+ description: "A record of another table \u2014 pick it, and its name is shown",
10991
+ group: "Choice",
10992
+ base: "string",
10993
+ alsoAccepts: ["array", "json"],
10994
+ editor: "select",
10995
+ rich: true,
10996
+ optionKeys: ["relation_target", "relation_max", "on_delete", "display"],
10997
+ // IT DOES NOT DELEGATE TO `choice`, AND THAT IS THE WHOLE POINT.
10998
+ // `choice.format` deliberately returns the RAW VALUE when no options have
10999
+ // resolved yet — a structured list still loading should not make every cell
11000
+ // look off-list. For a label column that is right: the raw value IS the
11001
+ // label. For an identifier column it is the defect §3.3 names, and it is
11002
+ // how the first cut of this wave put four raw uuids on a real grid: the
11003
+ // fallback arm never ran, because returning the uuid counted as SUCCESS.
11004
+ //
11005
+ // Here, a value that did not resolve to words is a MISMATCH — `null` — so
11006
+ // the renderer's three-state arm owns it and shows the amber fallback with
11007
+ // the id marked as an identifier, or the withheld sentence. The id never
11008
+ // reaches the eye as if it were the cell's contents.
11009
+ format: (v, options) => {
11010
+ const list = toList(v);
11011
+ const values = list !== null ? list : v == null ? [] : [v];
11012
+ if (values.length === 0) return "";
11013
+ const words2 = [];
11014
+ for (const entry of values) {
11015
+ const text = toText(entry);
11016
+ if (text === null) return null;
11017
+ const trimmed = text.trim();
11018
+ if (trimmed === "") continue;
11019
+ const match = findChoice(options.choices, trimmed);
11020
+ if (!match) return null;
11021
+ words2.push(match.label ?? match.value);
11022
+ }
11023
+ return words2.length === 0 ? "" : words2.join(", ");
11024
+ },
11025
+ parse: (raw) => FIELD_FORMATS.choice.parse(raw, {})
11026
+ },
10763
11027
  {
10764
11028
  id: "multi_choice",
10765
11029
  label: "Multi-choice",
@@ -10804,7 +11068,7 @@ var DEFS = [
10804
11068
  // A formula column stores NOTHING. `metadata.format.options.formula` holds
10805
11069
  // the expression; the stored cell is always null; the value is computed on
10806
11070
  // read from the other columns of the same row
10807
- // (`features/data-tables/formulas.ts`). Grouped under Structured on purpose
11071
+ // (`@ai-matrx/design-system/formulas`). Grouped under Structured on purpose
10808
11072
  // — the picker's groups are a fixed vocabulary, and a formula is structure
10809
11073
  // over a plain string column, not a sixth family.
10810
11074
  //