@object-ui/core 17.7.0 → 18.0.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 (52) hide show
  1. package/CHANGELOG.md +650 -0
  2. package/README.md +127 -0
  3. package/dist/actions/ActionRunner.d.ts +192 -49
  4. package/dist/actions/ActionRunner.js +389 -102
  5. package/dist/actions/UndoManager.d.ts +8 -13
  6. package/dist/actions/actionKeys.d.ts +1 -1
  7. package/dist/actions/actionKeys.js +10 -0
  8. package/dist/actions/index.d.ts +1 -0
  9. package/dist/actions/index.js +1 -0
  10. package/dist/actions/navigationTarget.d.ts +41 -0
  11. package/dist/actions/navigationTarget.js +20 -0
  12. package/dist/actions/recordDelete.d.ts +59 -23
  13. package/dist/actions/recordDelete.js +40 -42
  14. package/dist/adapters/ApiDataSource.d.ts +8 -4
  15. package/dist/adapters/ApiDataSource.js +19 -7
  16. package/dist/adapters/resolveDataSource.d.ts +9 -6
  17. package/dist/adapters/resolveDataSource.js +9 -12
  18. package/dist/data-scope/ViewDataProvider.d.ts +7 -24
  19. package/dist/data-scope/ViewDataProvider.js +6 -53
  20. package/dist/evaluator/deadlineSettled.d.ts +40 -0
  21. package/dist/evaluator/deadlineSettled.js +45 -0
  22. package/dist/evaluator/fieldRules.js +6 -1
  23. package/dist/evaluator/index.d.ts +1 -0
  24. package/dist/evaluator/index.js +1 -0
  25. package/dist/evaluator/optionRules.d.ts +1 -1
  26. package/dist/evaluator/optionRules.js +1 -1
  27. package/dist/evaluator/subjectPermissions.d.ts +30 -1
  28. package/dist/evaluator/subjectPermissions.js +42 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +3 -0
  31. package/dist/registry/Registry.d.ts +34 -13
  32. package/dist/registry/Registry.js +5 -22
  33. package/dist/utils/affordanceGrants.d.ts +371 -0
  34. package/dist/utils/affordanceGrants.js +252 -0
  35. package/dist/utils/chart-series.d.ts +34 -0
  36. package/dist/utils/chart-series.js +25 -2
  37. package/dist/utils/date-display.d.ts +42 -50
  38. package/dist/utils/date-display.js +192 -11
  39. package/dist/utils/expand-fields.d.ts +6 -4
  40. package/dist/utils/expand-fields.js +6 -4
  41. package/dist/utils/filter-converter.d.ts +6 -2
  42. package/dist/utils/filter-converter.js +6 -2
  43. package/dist/utils/normalize-list-view.d.ts +16 -12
  44. package/dist/utils/normalize-list-view.js +66 -67
  45. package/dist/utils/predicate-fields.js +6 -2
  46. package/dist/utils/record-source.d.ts +1 -1
  47. package/dist/utils/record-source.js +1 -1
  48. package/dist/utils/reference-keys.js +1 -1
  49. package/dist/validation/schema-validator.js +29 -5
  50. package/dist/validation/server-owned-value.d.ts +5 -1
  51. package/dist/validation/server-owned-value.js +5 -1
  52. package/package.json +4 -4
@@ -53,6 +53,22 @@ import { pivotBucketId, pivotDimensionValue } from './dataset-pivot.js';
53
53
  * and {@link resolveMeasureLabel} treats it as absent.
54
54
  */
55
55
  export const BUILTIN_AGGREGATES = ['count', 'sum', 'avg', 'min', 'max', 'count_distinct'];
56
+ /**
57
+ * The aggregates whose every value is a whole number: a count of rows, or of
58
+ * distinct values (objectui#11681, triage ruling Q3 A on that card). `sum` /
59
+ * `min` / `max` over an integer field would qualify too, but the answer does
60
+ * not state the source field's scale, so they are not judged integer here.
61
+ */
62
+ export const INTEGER_AGGREGATES = ['count', 'count_distinct'];
63
+ /**
64
+ * Whether a measure column's `aggregate` makes every value a whole number — a
65
+ * value axis that plots only such measures takes integer ticks
66
+ * (objectui#11681). Judged over an untrusted wire value; anything else,
67
+ * absence included, answers `false`, so the axis keeps its default.
68
+ */
69
+ export function isIntegerAggregate(value) {
70
+ return typeof value === 'string' && INTEGER_AGGREGATES.includes(value);
71
+ }
56
72
  /** Runtime membership test for {@link BUILTIN_AGGREGATES}, over an untrusted wire value. */
57
73
  export function isBuiltinAggregate(value) {
58
74
  return typeof value === 'string' && BUILTIN_AGGREGATES.includes(value);
@@ -344,6 +360,12 @@ export function buildChartSeries(rows, dimensions, values, fields, options) {
344
360
  const field = (fields ?? []).find((f) => f.name === name);
345
361
  return field ? resolveMeasureLabel(field, options?.builtinAggregateLabels) : name;
346
362
  };
363
+ // The measure column's own `aggregate`, spread onto each series that plots
364
+ // it (objectui#11681) — present only when the column states a string one.
365
+ const aggregateOf = (name) => {
366
+ const aggregate = (fields ?? []).find((f) => f.name === name)?.aggregate;
367
+ return typeof aggregate === 'string' ? { aggregate } : {};
368
+ };
347
369
  // Multi-dimension, single-measure → pivot the second dimension into series.
348
370
  if (dims.length >= 2 && vals.length === 1) {
349
371
  const xKey = dims[0];
@@ -449,7 +471,8 @@ export function buildChartSeries(rows, dimensions, values, fields, options) {
449
471
  // server-resolved to display labels by queryDataset), except the null
450
472
  // group, which says so with the bucket label the renderer supplied. The
451
473
  // key is a separate question from the text — see `pivotSeriesBuckets`.
452
- series: groups.map(({ dataKey, label }) => ({ dataKey, label })),
474
+ // Every group plots the one measure, so each carries its aggregate.
475
+ series: groups.map(({ dataKey, label }) => ({ dataKey, label, ...aggregateOf(measure) })),
453
476
  };
454
477
  }
455
478
  // Default: first dimension on the x-axis, one series per measure.
@@ -472,7 +495,7 @@ export function buildChartSeries(rows, dimensions, values, fields, options) {
472
495
  ? bucketNullCategories(safeRows, xKey, options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL)
473
496
  : safeRows,
474
497
  xAxisKey: xKey,
475
- series: vals.map((v) => ({ dataKey: v, label: labelOf(v) })),
498
+ series: vals.map((v) => ({ dataKey: v, label: labelOf(v), ...aggregateOf(v) })),
476
499
  };
477
500
  }
478
501
  /**
@@ -6,56 +6,37 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  /**
9
- * date-display - the ONE date/datetime display path behind every field cell,
10
- * grid card, gantt tooltip and dataset measure in the console.
11
- *
12
- * These functions are unchanged; what changed is where they live. They were
13
- * written in `@object-ui/fields`' barrel (`packages/fields/src/index.tsx`),
14
- * which is a React package, so `@object-ui/core` could not reach them - and
15
- * `core`'s `utils/dataset-format.ts` is exactly the caller that needed them:
16
- * `formatMeasure` returned `String(v)` for any non-numeric value, so a `min` /
17
- * `max` measure over a date field rendered its raw 24-character ISO string on
18
- * the metric tile, in chart values, in dataset table cells and in the
19
- * metadata-admin dataset preview (objectui#7178).
20
- *
21
- * The fix is the move, NOT a second formatter here. That choice is the whole
22
- * point, and it is the lesson of objectui#4576: when the percent convention
23
- * was duplicated across this same package boundary, a list cell and a
24
- * dashboard measure drifted apart (`1.234,5 %` beside `1.234,5%` in a German
25
- * session) while both were "correct". `dataset-format.ts`'s own header records
26
- * that history, and `number-display.ts` next door is the same remedy applied
27
- * to numbers: the pure function moves DOWN into the React-free engine and the
28
- * upper package re-exports it, so there is one home and nothing to drift.
29
- *
30
- * `@object-ui/fields` re-exports the formatting symbols below under their
31
- * original names, so `formatDate` / `formatDateTime` /
32
- * `formatDateTimeCompactParts` / `formatRelativeDate` / `DateDisplayOptions`
33
- * keep working unchanged for `ObjectGrid`, `ObjectGantt`,
34
- * `plugin-dashboard`'s `recordFields` and the `date` cell renderer.
35
- * `toDisplayDate` is exported from `@object-ui/core` alone (objectui#10183),
36
- * and so are `toDisplayEndDate` / `toInclusiveEndDay`, the end read and its
37
- * inverse both gantt surfaces share (objectui#11141).
38
- *
39
- * The `datetime` CELL face joined this file in objectui#7443. It used to be a
40
- * second convention inlined in `DateTimeCellRenderer`: two `Intl` option bags
41
- * for one field type, kept in step by nothing, while `date` had exactly one.
42
- * It is `formatDateTime`'s `'compact'` style now, byte-identical to what the
43
- * cell rendered before.
44
- *
45
- * Pure by construction (no React, no i18n): the only ambient inputs are `Intl`
46
- * and the clock, and the one phrase `Intl` cannot produce ("Overdue Nd") comes
47
- * in through the INJECTED `options.t`, the same way `buildDatasetFieldHelpers`
48
- * in `dataset-format.ts` takes `fieldLabel`.
49
- *
50
- * ⚠️ There is a third ambient input, and it is the one this module has to
51
- * decide about rather than pass on: the VIEWER's timezone. A value carrying a
52
- * time is an instant and renders in that zone; a DATE-ONLY value names a
53
- * calendar day, carries no instant, and must render as that day everywhere.
54
- * `toDisplayDate` below is the single parse step that tells the two apart —
55
- * every function here goes through it (objectui#10110), and so does every
56
- * caller elsewhere that needs a face or a day comparison none of these
57
- * functions produce (objectui#10183).
9
+ * Declare the zone instants render in — the host's half of objectui#11693.
10
+ *
11
+ * ## Why the zone enters HERE and nowhere else
12
+ *
13
+ * Every date and datetime face in the console is a function of this module
14
+ * (see the header), and each caller threads only its `locale`. The zone the
15
+ * signed-in workspace configures (`localization.timezone`, served by
16
+ * `GET /api/v1/auth/me/localization`) is the same for every face on the
17
+ * page, so it is set once, here, and every face reads it — rather than
18
+ * threaded through each call site, where one forgotten caller renders the
19
+ * same instant in a second zone beside the first (objectui#4272 is that
20
+ * defect for the locale). `LocalizationProvider` (`@object-ui/i18n`) is the
21
+ * host that calls this; a renderer never does.
22
+ *
23
+ * `undefined` (or an empty string) clears it: instants render in the
24
+ * viewer's zone again. A name this runtime's `Intl` does not know — a zone
25
+ * newer than the browser's time-zone data, say — clears it too, with a
26
+ * warning, rather than make every face throw the `RangeError` `Intl` raises
27
+ * for it.
28
+ *
29
+ * ⛔ A date-only value never takes the zone; see {@link toDisplayDate}.
58
30
  */
31
+ export declare function setDisplayTimeZone(timeZone: string | undefined): void;
32
+ /** The zone {@link setDisplayTimeZone} declared, or `undefined` for the viewer's own. */
33
+ export declare function getDisplayTimeZone(): string | undefined;
34
+ /**
35
+ * Be told when the display zone changes; returns the unsubscribe. The shape is
36
+ * React's `useSyncExternalStore` contract, which is how `LocalizationProvider`
37
+ * re-renders its consumers once the zone they format in has moved.
38
+ */
39
+ export declare function subscribeDisplayTimeZone(listener: () => void): () => void;
59
40
  /**
60
41
  * Options shared by {@link formatDate} / {@link formatRelativeDate} /
61
42
  * {@link formatDateTime}. One bag, and each function reads the keys it needs:
@@ -207,7 +188,9 @@ export declare function isRealCalendarDate(dateOnly: string): boolean;
207
188
  * ⛔ Read the result with LOCAL getters or local-zone `Intl` formatting only.
208
189
  * For a date-only value it is local midnight of the named day, so its
209
190
  * `toISOString()` / UTC getters name the previous day east of UTC — hand
210
- * those the stored value, never this.
191
+ * those the stored value, never this. The faces in this module know such a
192
+ * `Date` when they are handed one and keep it out of the display zone
193
+ * (objectui#11693, see `calendarDays`).
211
194
  */
212
195
  export declare function toDisplayDate(value: string | Date | number): Date;
213
196
  /**
@@ -269,6 +252,11 @@ export declare function toInclusiveEndDay(end: Date): Date;
269
252
  * past dates render as plain "N days ago" instead. The overdue phrase has no
270
253
  * `Intl` equivalent, so it resolves through `options.t` (key
271
254
  * `fields.relativeDate.overdue`) with an English fallback.
255
+ *
256
+ * Days are counted in the display zone (objectui#11693): "today" is the
257
+ * display zone's today, and an instant falls on its day there. A date-only
258
+ * value keeps the day it names and is counted from that same today, so an
259
+ * instant and a date-only value on one day read one phrase.
272
260
  */
273
261
  export declare function formatRelativeDate(value: string | Date | number, options?: DateDisplayOptions): string;
274
262
  /**
@@ -350,5 +338,9 @@ export declare function formatDateTimeCompactParts(value: string | Date | number
350
338
  * hard it tried — it always handed `Intl` an `undefined` tag, i.e. the
351
339
  * MACHINE's locale, which is neither of the repo's two locale channels.
352
340
  * Callers should pass the tag from `useDisplayLocale()`.
341
+ *
342
+ * The zone is NOT an option: an instant renders in the display zone the host
343
+ * declared with {@link setDisplayTimeZone}, else the viewer's, on every face
344
+ * this module produces (objectui#11693).
353
345
  */
354
346
  export declare function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string;
@@ -5,6 +5,169 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ /**
9
+ * date-display - the ONE date/datetime display path behind every field cell,
10
+ * grid card, gantt tooltip and dataset measure in the console.
11
+ *
12
+ * These functions are unchanged; what changed is where they live. They were
13
+ * written in `@object-ui/fields`' barrel (`packages/fields/src/index.tsx`),
14
+ * which is a React package, so `@object-ui/core` could not reach them - and
15
+ * `core`'s `utils/dataset-format.ts` is exactly the caller that needed them:
16
+ * `formatMeasure` returned `String(v)` for any non-numeric value, so a `min` /
17
+ * `max` measure over a date field rendered its raw 24-character ISO string on
18
+ * the metric tile, in chart values, in dataset table cells and in the
19
+ * metadata-admin dataset preview (objectui#7178).
20
+ *
21
+ * The fix is the move, NOT a second formatter here. That choice is the whole
22
+ * point, and it is the lesson of objectui#4576: when the percent convention
23
+ * was duplicated across this same package boundary, a list cell and a
24
+ * dashboard measure drifted apart (`1.234,5 %` beside `1.234,5%` in a German
25
+ * session) while both were "correct". `dataset-format.ts`'s own header records
26
+ * that history, and `number-display.ts` next door is the same remedy applied
27
+ * to numbers: the pure function moves DOWN into the React-free engine and the
28
+ * upper package re-exports it, so there is one home and nothing to drift.
29
+ *
30
+ * `@object-ui/fields` re-exports the formatting symbols below under their
31
+ * original names, so `formatDate` / `formatDateTime` /
32
+ * `formatDateTimeCompactParts` / `formatRelativeDate` / `DateDisplayOptions`
33
+ * keep working unchanged for `ObjectGrid`, `ObjectGantt`,
34
+ * `plugin-dashboard`'s `recordFields` and the `date` cell renderer.
35
+ * `toDisplayDate` is exported from `@object-ui/core` alone (objectui#10183),
36
+ * and so are `toDisplayEndDate` / `toInclusiveEndDay`, the end read and its
37
+ * inverse both gantt surfaces share (objectui#11141).
38
+ *
39
+ * The `datetime` CELL face joined this file in objectui#7443. It used to be a
40
+ * second convention inlined in `DateTimeCellRenderer`: two `Intl` option bags
41
+ * for one field type, kept in step by nothing, while `date` had exactly one.
42
+ * It is `formatDateTime`'s `'compact'` style now, byte-identical to what the
43
+ * cell rendered before.
44
+ *
45
+ * No React, no i18n: the ambient inputs are `Intl`, the clock and the display
46
+ * zone below, and the one phrase `Intl` cannot produce ("Overdue Nd") comes
47
+ * in through the INJECTED `options.t`, the same way `buildDatasetFieldHelpers`
48
+ * in `dataset-format.ts` takes `fieldLabel`.
49
+ *
50
+ * ⚠️ There is a third ambient input, and it is the one this module has to
51
+ * decide about rather than pass on: the zone an instant renders in. A value
52
+ * carrying a time is an instant and renders in the DISPLAY ZONE — the zone the
53
+ * host declared through {@link setDisplayTimeZone} (objectui#11693), else the
54
+ * viewer's own; a DATE-ONLY value names a calendar day, carries no instant,
55
+ * and must render as that day everywhere, whatever the display zone.
56
+ * `toDisplayDate` below is the single parse step that tells the two apart —
57
+ * every function here goes through it (objectui#10110), and so does every
58
+ * caller elsewhere that needs a face or a day comparison none of these
59
+ * functions produce (objectui#10183).
60
+ */
61
+ // ── The display zone (objectui#11693) ──────────────────────────────────────
62
+ /**
63
+ * The zone every instant face below renders in, when the host has declared
64
+ * one; `undefined` means the viewer's own zone (the runtime default), which is
65
+ * what every face rendered in before objectui#11693.
66
+ */
67
+ let displayTimeZone;
68
+ const displayTimeZoneListeners = new Set();
69
+ /** `true` when this runtime's `Intl` knows `timeZone`. */
70
+ function isKnownTimeZone(timeZone) {
71
+ try {
72
+ new Intl.DateTimeFormat('en-US', { timeZone });
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * Declare the zone instants render in — the host's half of objectui#11693.
81
+ *
82
+ * ## Why the zone enters HERE and nowhere else
83
+ *
84
+ * Every date and datetime face in the console is a function of this module
85
+ * (see the header), and each caller threads only its `locale`. The zone the
86
+ * signed-in workspace configures (`localization.timezone`, served by
87
+ * `GET /api/v1/auth/me/localization`) is the same for every face on the
88
+ * page, so it is set once, here, and every face reads it — rather than
89
+ * threaded through each call site, where one forgotten caller renders the
90
+ * same instant in a second zone beside the first (objectui#4272 is that
91
+ * defect for the locale). `LocalizationProvider` (`@object-ui/i18n`) is the
92
+ * host that calls this; a renderer never does.
93
+ *
94
+ * `undefined` (or an empty string) clears it: instants render in the
95
+ * viewer's zone again. A name this runtime's `Intl` does not know — a zone
96
+ * newer than the browser's time-zone data, say — clears it too, with a
97
+ * warning, rather than make every face throw the `RangeError` `Intl` raises
98
+ * for it.
99
+ *
100
+ * ⛔ A date-only value never takes the zone; see {@link toDisplayDate}.
101
+ */
102
+ export function setDisplayTimeZone(timeZone) {
103
+ let next;
104
+ if (timeZone) {
105
+ if (isKnownTimeZone(timeZone))
106
+ next = timeZone;
107
+ else
108
+ console.warn(`[date-display] unknown time zone "${timeZone}"; instants render in the viewer's zone`);
109
+ }
110
+ if (next === displayTimeZone)
111
+ return;
112
+ displayTimeZone = next;
113
+ for (const listener of [...displayTimeZoneListeners])
114
+ listener();
115
+ }
116
+ /** The zone {@link setDisplayTimeZone} declared, or `undefined` for the viewer's own. */
117
+ export function getDisplayTimeZone() {
118
+ return displayTimeZone;
119
+ }
120
+ /**
121
+ * Be told when the display zone changes; returns the unsubscribe. The shape is
122
+ * React's `useSyncExternalStore` contract, which is how `LocalizationProvider`
123
+ * re-renders its consumers once the zone they format in has moved.
124
+ */
125
+ export function subscribeDisplayTimeZone(listener) {
126
+ displayTimeZoneListeners.add(listener);
127
+ return () => {
128
+ displayTimeZoneListeners.delete(listener);
129
+ };
130
+ }
131
+ /**
132
+ * The `Date`s this module built to NAME A CALENDAR DAY — {@link toDisplayDate}'s
133
+ * local midnight for a date-only value, and {@link toInclusiveEndDay}'s day.
134
+ *
135
+ * The faces below must read such a `Date` in the local zone (its own docblock
136
+ * says so), never in the display zone: west of the viewer it would name the
137
+ * day before. A string still says what it is, but callers hand these `Date`s
138
+ * on — `GridField`'s date cell formats the one `toDisplayDate` returned, and
139
+ * the functions here hand theirs to each other — so the fact travels with the
140
+ * object rather than with the string it came from.
141
+ */
142
+ const calendarDays = new WeakSet();
143
+ /** The zone a parsed value renders in: the display zone for an instant, the viewer's for a calendar day. */
144
+ function zoneFor(date) {
145
+ return calendarDays.has(date) ? undefined : displayTimeZone;
146
+ }
147
+ /** `Intl` options naming `timeZone`, or none, so a face with no zone keeps its exact options bag. */
148
+ function inZone(timeZone) {
149
+ return timeZone ? { timeZone } : {};
150
+ }
151
+ const dayPartsFormats = new Map();
152
+ /** The calendar day `date` falls on in `timeZone` (the viewer's when `undefined`); `month` is 0-based. */
153
+ function dayIn(date, timeZone) {
154
+ if (!timeZone)
155
+ return { year: date.getFullYear(), month: date.getMonth(), day: date.getDate() };
156
+ let format = dayPartsFormats.get(timeZone);
157
+ if (!format) {
158
+ format = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' });
159
+ dayPartsFormats.set(timeZone, format);
160
+ }
161
+ const parts = format.formatToParts(date);
162
+ const part = (type) => Number(parts.find((p) => p.type === type)?.value);
163
+ return { year: part('year'), month: part('month') - 1, day: part('day') };
164
+ }
165
+ /** A day as a whole number of days, so two days subtract to their distance with no DST hour in it. */
166
+ function dayNumber({ year, month, day }) {
167
+ const probe = new Date(0);
168
+ probe.setUTCFullYear(year, month, day);
169
+ return Math.round(probe.getTime() / 86400000);
170
+ }
8
171
  /**
9
172
  * The date-only ISO spelling — `2026-08-01`, and nothing else.
10
173
  *
@@ -147,7 +310,9 @@ export function isRealCalendarDate(dateOnly) {
147
310
  * ⛔ Read the result with LOCAL getters or local-zone `Intl` formatting only.
148
311
  * For a date-only value it is local midnight of the named day, so its
149
312
  * `toISOString()` / UTC getters name the previous day east of UTC — hand
150
- * those the stored value, never this.
313
+ * those the stored value, never this. The faces in this module know such a
314
+ * `Date` when they are handed one and keep it out of the display zone
315
+ * (objectui#11693, see `calendarDays`).
151
316
  */
152
317
  export function toDisplayDate(value) {
153
318
  const parsed = value instanceof Date ? value : new Date(value);
@@ -169,6 +334,7 @@ export function toDisplayDate(value) {
169
334
  // Setting the year back is co-extensive with that legacy mapping and a
170
335
  // no-op on every other year.
171
336
  local.setFullYear(year);
337
+ calendarDays.add(local);
172
338
  return local;
173
339
  }
174
340
  /**
@@ -233,6 +399,7 @@ export function toDisplayEndDate(value) {
233
399
  export function toInclusiveEndDay(end) {
234
400
  const day = new Date(end.getTime() - 1);
235
401
  day.setHours(0, 0, 0, 0);
402
+ calendarDays.add(day);
236
403
  return day;
237
404
  }
238
405
  /**
@@ -289,6 +456,11 @@ function absoluteFallbackOptions(options) {
289
456
  * past dates render as plain "N days ago" instead. The overdue phrase has no
290
457
  * `Intl` equivalent, so it resolves through `options.t` (key
291
458
  * `fields.relativeDate.overdue`) with an English fallback.
459
+ *
460
+ * Days are counted in the display zone (objectui#11693): "today" is the
461
+ * display zone's today, and an instant falls on its day there. A date-only
462
+ * value keeps the day it names and is counted from that same today, so an
463
+ * instant and a date-only value on one day read one phrase.
292
464
  */
293
465
  export function formatRelativeDate(value, options) {
294
466
  if (value === null || value === undefined || value === '')
@@ -296,11 +468,7 @@ export function formatRelativeDate(value, options) {
296
468
  const date = toDisplayDate(value);
297
469
  if (!(date instanceof Date) || isNaN(date.getTime()))
298
470
  return '—';
299
- const now = new Date();
300
- const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
301
- const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
302
- const diffMs = startOfDate.getTime() - startOfToday.getTime();
303
- const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
471
+ const diffDays = dayNumber(dayIn(date, zoneFor(date))) - dayNumber(dayIn(new Date(), displayTimeZone));
304
472
  // Beyond the ±7-day window, fall back to the absolute (already localized) form.
305
473
  if (diffDays < -7 || diffDays > 7)
306
474
  return formatDate(date, undefined, absoluteFallbackOptions(options));
@@ -354,6 +522,9 @@ export function formatDate(value, style, options) {
354
522
  if (!(date instanceof Date) || isNaN(date.getTime()))
355
523
  return '—';
356
524
  const effectiveStyle = style ?? options?.style;
525
+ // An instant names its day in the display zone; a calendar day is its own
526
+ // day in every zone (objectui#11693).
527
+ const zone = zoneFor(date);
357
528
  if (effectiveStyle === 'short') {
358
529
  // Compact format for mobile: "Jan 15, '24" / "1月 15, '24".
359
530
  // Only the MONTH token is localized: the surrounding compact shape (day,
@@ -361,10 +532,9 @@ export function formatDate(value, style, options) {
361
532
  // cards, not a locale-derived one. The tag comes from `options.locale`
362
533
  // like the default branch below — hardcoding `'en-US'` here made this the
363
534
  // one branch that ignored a locale its caller had threaded (objectui#4272).
364
- const month = date.toLocaleDateString(options?.locale, { month: 'short' });
365
- const day = date.getDate();
366
- const year = String(date.getFullYear()).slice(-2);
367
- return `${month} ${day}, '${year}`;
535
+ const month = date.toLocaleDateString(options?.locale, { month: 'short', ...inZone(zone) });
536
+ const { day, year } = dayIn(date, zone);
537
+ return `${month} ${day}, '${String(year).slice(-2)}`;
368
538
  }
369
539
  if (effectiveStyle === 'relative') {
370
540
  return formatRelativeDate(date, options);
@@ -374,11 +544,12 @@ export function formatDate(value, style, options) {
374
544
  // because the year is rarely useful for in-progress records and the
375
545
  // verbose "2026年7月21日" form crowds cards and table cells. Past- /
376
546
  // future-year dates keep the year so users can disambiguate.
377
- const isCurrentYear = date.getFullYear() === new Date().getFullYear();
547
+ const isCurrentYear = dayIn(date, zone).year === dayIn(new Date(), displayTimeZone).year;
378
548
  return date.toLocaleDateString(options?.locale, {
379
549
  year: isCurrentYear ? undefined : 'numeric',
380
550
  month: 'short',
381
551
  day: 'numeric',
552
+ ...inZone(zone),
382
553
  });
383
554
  }
384
555
  /**
@@ -402,11 +573,15 @@ export function formatDateTimeCompactParts(value, options) {
402
573
  const date = toDisplayDate(value);
403
574
  if (!(date instanceof Date) || isNaN(date.getTime()))
404
575
  return null;
576
+ // Both halves in the display zone, so the day and the time of one instant
577
+ // never come from two zones (objectui#11693).
578
+ const zone = zoneFor(date);
405
579
  return {
406
580
  date: date.toLocaleDateString(options?.locale, {
407
581
  month: 'numeric',
408
582
  day: 'numeric',
409
583
  year: 'numeric',
584
+ ...inZone(zone),
410
585
  }),
411
586
  // `hour12` stays declared: this is the compact Airtable-style cell, and
412
587
  // the 12-hour face is its design, not a locale artefact. Locales that
@@ -415,6 +590,7 @@ export function formatDateTimeCompactParts(value, options) {
415
590
  hour: 'numeric',
416
591
  minute: '2-digit',
417
592
  hour12: true,
593
+ ...inZone(zone),
418
594
  }).toLowerCase(),
419
595
  };
420
596
  }
@@ -442,6 +618,10 @@ export function formatDateTimeCompactParts(value, options) {
442
618
  * hard it tried — it always handed `Intl` an `undefined` tag, i.e. the
443
619
  * MACHINE's locale, which is neither of the repo's two locale channels.
444
620
  * Callers should pass the tag from `useDisplayLocale()`.
621
+ *
622
+ * The zone is NOT an option: an instant renders in the display zone the host
623
+ * declared with {@link setDisplayTimeZone}, else the viewer's, on every face
624
+ * this module produces (objectui#11693).
445
625
  */
446
626
  export function formatDateTime(value, options) {
447
627
  if (value === null || value === undefined || value === '')
@@ -459,5 +639,6 @@ export function formatDateTime(value, options) {
459
639
  day: 'numeric',
460
640
  hour: '2-digit',
461
641
  minute: '2-digit',
642
+ ...inZone(zoneFor(date)),
462
643
  });
463
644
  }
@@ -98,7 +98,8 @@
98
98
  * - the dashboard's relation/link test — `isLookupType` in
99
99
  * `packages/plugin-dashboard/src/recordFields.tsx` (objectui#5692);
100
100
  * - the action-param RESOLVER (one step before `paramToField`) —
101
- * `packages/app-shell/src/utils/resolveActionParams.ts` (objectui#5874);
101
+ * `packages/fields/src/resolveActionParams.ts` (objectui#5874; moved out of
102
+ * app-shell by objectui#12105 so the bulk dialog shares it);
102
103
  * - the detail header's relation test — `HeaderHighlight.tsx` in
103
104
  * `packages/plugin-detail` (objectui#5874);
104
105
  * - the detail drawer's relation test — `RecordDetailDrawer.tsx` in
@@ -134,10 +135,11 @@
134
135
  * (objectui#4790). The form now DERIVES from this set instead of restating it:
135
136
  *
136
137
  * form's data-source rule == EXPANDABLE_FIELD_TYPES
137
- * + three widget-hint-only picker names
138
+ * + the widget-hint-only picker names
138
139
  *
139
140
  * The extension is one-directional and cannot fold back into this set:
140
- * `object-ref` / `filter-condition` / `recipient-picker` are widget HINTS, never
141
+ * `object-ref` / `filter-condition` / `recipient-picker` (and the registry-name
142
+ * pickers `position-ref` / `permission-ref`, objectui#7611) are widget HINTS, never
141
143
  * declarable field types (`widgetHintOnly: true` in
142
144
  * `app-shell/src/utils/paramValueShape.ts`), so no object schema can produce a
143
145
  * field whose `type` is one of them.
@@ -146,7 +148,7 @@
146
148
  * of the above (`lookup` / `master_detail` / `user` — no `tree`, no pickers, and
147
149
  * for a while a fifth spelling, `owner`, that objectui#4814 retired). It now
148
150
  * derives from this set with NO extension, because the bulk dialog's params
149
- * cannot produce the three widget-hint names (objectui#4815):
151
+ * cannot produce the widget-hint names (objectui#4815):
150
152
  *
151
153
  * bulk dialog's data-source rule == EXPANDABLE_FIELD_TYPES
152
154
  *
@@ -99,7 +99,8 @@ import { columnIdentity } from './column-identity.js';
99
99
  * - the dashboard's relation/link test — `isLookupType` in
100
100
  * `packages/plugin-dashboard/src/recordFields.tsx` (objectui#5692);
101
101
  * - the action-param RESOLVER (one step before `paramToField`) —
102
- * `packages/app-shell/src/utils/resolveActionParams.ts` (objectui#5874);
102
+ * `packages/fields/src/resolveActionParams.ts` (objectui#5874; moved out of
103
+ * app-shell by objectui#12105 so the bulk dialog shares it);
103
104
  * - the detail header's relation test — `HeaderHighlight.tsx` in
104
105
  * `packages/plugin-detail` (objectui#5874);
105
106
  * - the detail drawer's relation test — `RecordDetailDrawer.tsx` in
@@ -135,10 +136,11 @@ import { columnIdentity } from './column-identity.js';
135
136
  * (objectui#4790). The form now DERIVES from this set instead of restating it:
136
137
  *
137
138
  * form's data-source rule == EXPANDABLE_FIELD_TYPES
138
- * + three widget-hint-only picker names
139
+ * + the widget-hint-only picker names
139
140
  *
140
141
  * The extension is one-directional and cannot fold back into this set:
141
- * `object-ref` / `filter-condition` / `recipient-picker` are widget HINTS, never
142
+ * `object-ref` / `filter-condition` / `recipient-picker` (and the registry-name
143
+ * pickers `position-ref` / `permission-ref`, objectui#7611) are widget HINTS, never
142
144
  * declarable field types (`widgetHintOnly: true` in
143
145
  * `app-shell/src/utils/paramValueShape.ts`), so no object schema can produce a
144
146
  * field whose `type` is one of them.
@@ -147,7 +149,7 @@ import { columnIdentity } from './column-identity.js';
147
149
  * of the above (`lookup` / `master_detail` / `user` — no `tree`, no pickers, and
148
150
  * for a while a fifth spelling, `owner`, that objectui#4814 retired). It now
149
151
  * derives from this set with NO extension, because the bulk dialog's params
150
- * cannot produce the three widget-hint names (objectui#4815):
152
+ * cannot produce the widget-hint names (objectui#4815):
151
153
  *
152
154
  * bulk dialog's data-source rule == EXPANDABLE_FIELD_TYPES
153
155
  *
@@ -174,8 +174,12 @@ export declare function convertFiltersToAST(filter: Record<string, any>): Filter
174
174
  *
175
175
  * The third is the one that kept getting lost. Renderers tested `source.length
176
176
  * > 0` before using it, which is `undefined > 0` for an object — so a
177
- * `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
178
- * view returned every record. Silently: no error, just a wider answer.
177
+ * `table.defaultFilters` (declared `Record<string, any>` until objectui#6152
178
+ * round 10; the protocol's `ViewFilterRule` array since, and the record no
179
+ * longer validates there) was DROPPED and the view returned every record.
180
+ * Silently: no error, just a wider answer. That narrowing moved what an author
181
+ * may write, not this sink: the object arm below still lowers a record whatever
182
+ * caller hands it one.
179
183
  *
180
184
  * The FIRST never worked at all (objectui#3431). The array branch returned
181
185
  * every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
@@ -1256,8 +1256,12 @@ function viewFilterRuleToNode(rule) {
1256
1256
  *
1257
1257
  * The third is the one that kept getting lost. Renderers tested `source.length
1258
1258
  * > 0` before using it, which is `undefined > 0` for an object — so a
1259
- * `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
1260
- * view returned every record. Silently: no error, just a wider answer.
1259
+ * `table.defaultFilters` (declared `Record<string, any>` until objectui#6152
1260
+ * round 10; the protocol's `ViewFilterRule` array since, and the record no
1261
+ * longer validates there) was DROPPED and the view returned every record.
1262
+ * Silently: no error, just a wider answer. That narrowing moved what an author
1263
+ * may write, not this sink: the object arm below still lowers a record whatever
1264
+ * caller hands it one.
1261
1265
  *
1262
1266
  * The FIRST never worked at all (objectui#3431). The array branch returned
1263
1267
  * every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
@@ -199,6 +199,12 @@ export declare function isListViewVisualization(value: unknown): value is ListVi
199
199
  * stays absent, because the defaults are per-toggle (search/sort/filter/
200
200
  * rowHeight/group default ON, hideFields/rowColor default OFF) and belong to
201
201
  * the renderer, not to the vocabulary bridge.
202
+ * - the view's `inlineEdit` → `userActions.editInline` (objectui#5144), with
203
+ * the value carried over and an explicit `editInline` winning. `inlineEdit`
204
+ * stays on the result, because `ListView` seeds the grid's edit mode from
205
+ * it. See {@link foldsInlineEditIntoEditInline}. An absent pair stays
206
+ * absent here as well; `ListView` reads that as on, the spec's default
207
+ * (objectstack#22605).
202
208
  * - `aria: { label, describedBy }` → the spec's `AriaProps`
203
209
  * (`{ ariaLabel, ariaDescribedBy }`), and `sharing: { visibility, enabled }`
204
210
  * → the spec's `ViewSharing` (`{ type }`) — #2890 scope A step 5. `aria.live`
@@ -221,8 +227,8 @@ export declare function isListViewVisualization(value: unknown): value is ListVi
221
227
  * · an already-present `objectName` WINS — this fold only fills a gap, it
222
228
  * never re-points a binding that already resolves;
223
229
  * · `data` is NOT deleted. Every other fold deletes because the legacy key
224
- * has one meaning and one home; `data` has four providers, `api`/`value`
225
- * are read live in `ListView`, and the whole block is FORWARDED to child
230
+ * has one meaning and one home; `data` has more providers than
231
+ * `object`, `value` is read live in `ListView`, and the whole block is FORWARDED to child
226
232
  * views (the gantt branch), whose own `getDataConfig` reads `data` before
227
233
  * `objectName`. Deleting it for one provider would rewrite what a child
228
234
  * resolves. Nothing in `ListView` reads `data.provider === 'object'`, so
@@ -245,15 +251,13 @@ export declare function isListViewVisualization(value: unknown): value is ListVi
245
251
  * breaking change with no inventory. Runs AFTER the `fields` → `columns` fold
246
252
  * above, so a view that spells its column list the legacy way still gets its
247
253
  * entries canonicalized.
248
- * - the four PER-VIEW-TYPE config aliases phase 3 carried over
249
- * ({@link PER_VIEW_CONFIG_ALIASES}): `kanban.groupField` → `groupByField`,
250
- * `kanban.cardFields` → `columns`, `gallery.imageField` → `coverField`,
251
- * `timeline.dateField` → `startDateField`. One read-site changes behaviour
252
- * as a result, and it is a CORRECTION of the same inverted precedence A2
253
- * fixed for `densityMode`: `ListView`'s kanban adapter resolves
254
- * `cardFields || columns`, i.e. legacy over canonical, so a config carrying
255
- * BOTH used to render the legacy value. After the fold the canonical
256
- * `columns` reaches it. Every other reader of these four was already
257
- * canonical-first and is unaffected.
254
+ *
255
+ * No longer folded: the four PER-VIEW-TYPE config aliases phase 3 carried over
256
+ * (`kanban.groupField` → `groupByField`, `kanban.cardFields` → `columns`,
257
+ * `gallery.imageField` → `coverField`, `timeline.dateField` →
258
+ * `startDateField`). objectui#6152 round 14 retired that fold with every
259
+ * renderer-side reader of the four: each list-view door refuses them by name
260
+ * (rounds 11 and 12), and a stored block that still carries one passes through
261
+ * here untouched and binds nothing downstream.
258
262
  */
259
263
  export declare function normalizeListViewSchema<T>(schema: T): T;