Skip to content

Commit ef7b5ef

Browse files
os-zhuangclaude
andauthored
feat(spec): Field.number gains useGrouping presentation hint (#7768) (#7813)
* feat(spec): Field.number gains useGrouping presentation hint (#7768) FieldSchema gains an optional `useGrouping: boolean` (Option A, ruled 2026-08-11 on #7768, maintainer veto window open) so an authored number field can opt out of Intl.NumberFormat's digit grouping without losing numeric semantics -- the fix for years (Field.number({ scale: 0, min: 1900 })) rendering as "2,026" that downstream apps have worked around three times by converting to Field.text (hotcrm-heimao#35/#40/#59). No default is declared: absent defers to the renderer (interim heuristic today, locale default eventually -- objectui#4033's contract, not this package's). Threads through Field.number(...) automatically via the existing FieldInput shape, same as scale/min. Also: liveness ledger classifies the key `planned` (objectui#4033 is the pending consumer); authorable-surface/data.json, field.mdx and state-counts.md regenerated to match. * chore(spec): regenerate field docs/authorable-surface/liveness after main merge Wholesale regen (gen:docs, gen:schema's authorable-surface projection, gen:liveness-counts) to re-materialize artifacts that drifted from commits main picked up since this branch's last merge — the `internal` field key (#7728) and the `flows` translation surface's planned entries (#7763). check:generated: 13/13 green; check:liveness: green. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7dceed commit ef7b5ef

7 files changed

Lines changed: 158 additions & 2 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `Field.number` gains a `useGrouping` presentation hint (#7768)
6+
7+
`scale` was the only presentation-adjacent property a `number` field carried,
8+
and it governs decimal places, not digit grouping. Console number renderers
9+
construct `Intl.NumberFormat` with grouping unconditionally ON, so an
10+
ordinal/identifier integer authored as `Field.number({ scale: 0, min: 1900 })`
11+
(e.g. a year) renders `2,026` everywhere it is shown. Downstream apps hit this
12+
three times (hotcrm-heimao#35, #40, #59) and each time converted the field to
13+
`Field.text` to escape the comma — trading away numeric semantics (range
14+
validation, sort-as-number, arithmetic) for a display detail unrelated to the
15+
field's type.
16+
17+
**New:** `useGrouping?: boolean` on `FieldSchema` (flat, alongside
18+
`precision`/`scale`/`min`/`max`), threaded automatically through
19+
`Field.number(...)` and every other type's builder via the existing
20+
`FieldInput` shape — no builder special-casing needed, the same way
21+
`scale`/`min` travel today.
22+
23+
Deliberately three-valued and NO default declared:
24+
25+
- **absent** — the author has not judged whether this integer reads as a
26+
quantity or an identifier; the renderer decides (an interim heuristic today,
27+
the locale's own default eventually — that contract lives in objectui, not
28+
here).
29+
- **`false`** — the author's explicit opt-out: never group this number
30+
(a year, an ID, a zip code).
31+
- **`true`** — the author pins grouping on, overriding the heuristic the
32+
other way.
33+
34+
Maps 1:1 onto `Intl.NumberFormat`'s `useGrouping`. This is Option A of the
35+
card's fork — the narrowest shape with measured pull — ruled on #7768,
36+
2026-08-11, with the maintainer's veto window open. No `displayFormat` slot,
37+
no other presentation knobs.
38+
39+
Unblocks objectui#4033, the console renderer half that consumes the explicit
40+
hint and retires the interim heuristic (explicit-hint > heuristic >
41+
locale-default).

content/docs/references/data/field.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const result = CurrencyConfigSchema.parse(data);
7171
| **scale** | `number` | optional | Decimal places |
7272
| **min** | `number` | optional | Minimum value |
7373
| **max** | `number` | optional | Maximum value |
74+
| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. |
7475
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
7576
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
7677
| **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect |

packages/spec/authorable-surface/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@
416416
"data/Field:trackHistory",
417417
"data/Field:type",
418418
"data/Field:unique",
419+
"data/Field:useGrouping",
419420
"data/Field:visibleWhen",
420421
"data/Field:widget",
421422
"data/FieldReference:$field",

packages/spec/liveness/field.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@
172172
"status": "live",
173173
"note": "CAVEAT — grid formatting only; DDL never sizes."
174174
},
175+
"useGrouping": {
176+
"status": "planned",
177+
"note": "[#7768] Declared 2026-08-11 — Option A of the card's fork (narrowest shape with measured pull, maintainer veto window open). Maps 1:1 to `Intl.NumberFormat`'s `useGrouping`. The runtime consumer is objectui#4033 (in flight at declaration time): its interim heuristic (`scale === 0 && no currency` ⇒ ungrouped) is explicitly documented at its own definition as \"overridden by the spec presentation hint when it lands\" — i.e. the read side is designed to pick this key up, not a speculative future phase. Not `authorWarn`'d: unlike `externalSharingModel`'s scheduled-phase precedent, an author who sets this today loses nothing and is not misled — the value becomes effective the moment the objectui consumer lands, no re-authoring needed. Re-verify to `live` once #4033 (or its successor) actually reads the key."
178+
},
175179
"reference": {
176180
"status": "live",
177181
"evidence": "packages/objectql/src/engine.ts:1672",

packages/spec/liveness/state-counts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ for both corollaries.
2828
| Type | live | exp | dead | planned | classified |
2929
|---|---|---|---|---|---|
3030
| `object` | 49 | 0 | 0 | 1 | 50 |
31-
| `field` | 67 | 0 | 0 | 0 | 67 |
31+
| `field` | 67 | 0 | 0 | 1 | 68 |
3232
| `flow` | 34 | 0 | 6 | 0 | 40 |
3333
| `action` | 42 | 0 | 2 | 0 | 44 |
3434
| `hook` | 18 | 0 | 2 | 0 | 20 |
@@ -57,4 +57,4 @@ for both corollaries.
5757
| `api` | 25 | 0 | 0 | 2 | 27 |
5858
| `capability` | 12 | 0 | 0 | 0 | 12 |
5959
| `qa` | 4 | 0 | 5 | 0 | 9 |
60-
| **total** | **777** | **6** | **52** | **7** | **842** |
60+
| **total** | **777** | **6** | **52** | **8** | **843** |

packages/spec/src/data/field.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2+
import { z } from 'zod';
23
import {
34
FieldSchema,
45
FieldType,
@@ -239,6 +240,75 @@ describe('FieldSchema', () => {
239240
});
240241
});
241242

243+
describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => {
244+
it('accepts an explicit `false` (author opts out of grouping — e.g. a year)', () => {
245+
const yearField: Field = {
246+
name: 'founded_year',
247+
label: 'Founded Year',
248+
type: 'number',
249+
scale: 0,
250+
min: 1900,
251+
useGrouping: false,
252+
};
253+
const result = FieldSchema.parse(yearField);
254+
expect(result.useGrouping).toBe(false);
255+
});
256+
257+
it('accepts an explicit `true` (author pins grouping on)', () => {
258+
const result = FieldSchema.parse({ type: 'number', useGrouping: true });
259+
expect(result.useGrouping).toBe(true);
260+
});
261+
262+
it('is optional — absent stays absent, no default materializes', () => {
263+
const result = FieldSchema.parse({ type: 'number' }) as Record<string, unknown>;
264+
expect(result.useGrouping).toBeUndefined();
265+
expect('useGrouping' in result).toBe(false);
266+
});
267+
268+
it('rejects a non-boolean value', () => {
269+
expect(() => FieldSchema.parse({ type: 'number', useGrouping: 'true' })).toThrow();
270+
expect(() => FieldSchema.parse({ type: 'number', useGrouping: 1 })).toThrow();
271+
expect(() => FieldSchema.parse({ type: 'number', useGrouping: null })).toThrow();
272+
});
273+
274+
it('is not type-restricted at the schema level (flat on FieldSchema, like scale/min)', () => {
275+
// FieldSchema does not discriminate its constraint keys by `type` — same
276+
// posture as `scale`/`min`, which parse on any field type too. A type-aware
277+
// "only meaningful on number/currency/percent" restriction is a renderer/lint
278+
// concern, not a parse-time one.
279+
expect(() => FieldSchema.parse({ type: 'text', useGrouping: false })).not.toThrow();
280+
});
281+
282+
it('does not disturb FieldSchema unknown-key strictness (#4001)', () => {
283+
expect(() => FieldSchema.parse({
284+
type: 'number',
285+
useGrouping: false,
286+
totallyBogusKey: true,
287+
} as unknown as Field)).toThrow(/Unrecognized key/);
288+
});
289+
290+
it('Field.number(...) threads useGrouping through like scale/min (no special-casing needed)', () => {
291+
const f = Field.number({ label: 'Founded Year', scale: 0, min: 1900, useGrouping: false });
292+
expect(f).toEqual({ type: 'number', label: 'Founded Year', scale: 0, min: 1900, useGrouping: false });
293+
expect(() => FieldSchema.parse(f)).not.toThrow();
294+
});
295+
296+
it('declares a boolean JSON-Schema slot with NO default — absence defers to the renderer', () => {
297+
const js = z.toJSONSchema(FieldSchema as unknown as z.ZodType, {
298+
unrepresentable: 'any',
299+
io: 'input',
300+
}) as any;
301+
const prop = js.properties?.useGrouping;
302+
expect(prop).toBeDefined();
303+
expect(prop.type).toBe('boolean');
304+
// Unlike `autonumberFormat`, this key carries no JSON-Schema `default`
305+
// annotation — there is no renderer-agnostic grouping behavior to declare
306+
// until the objectui half (#4033) retires the interim heuristic.
307+
expect(prop.default).toBeUndefined();
308+
expect(js.required ?? []).not.toContain('useGrouping');
309+
});
310+
});
311+
242312
describe('Select Field', () => {
243313
it('should accept select field with options', () => {
244314
const selectField: Field = {

packages/spec/src/data/field.zod.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,45 @@ export const FieldSchema = lazySchema(() => strictObject({
550550
scale: z.number().optional().describe('Decimal places'),
551551
min: z.number().optional().describe('Minimum value'),
552552
max: z.number().optional().describe('Maximum value'),
553+
/**
554+
* Presentation hint (#7768): whether a `number` field renders with digit
555+
* grouping (`Intl.NumberFormat`'s `useGrouping`, e.g. `2,026` vs `2026`).
556+
* `scale` was the ONLY presentation-adjacent property `number` had, and it
557+
* governs decimal places, not grouping — console renderers construct
558+
* `Intl.NumberFormat` with grouping unconditionally ON, so an
559+
* ordinal/identifier integer stored as `Field.number({ scale: 0, min: 1900
560+
* })` (a year) renders `2,026` everywhere it is shown. Downstream apps hit
561+
* this three times (hotcrm-heimao#35/#40/#59) and each time converted the
562+
* field to `Field.text` to escape the comma — trading away numeric
563+
* semantics (range validation, sort-as-number, arithmetic) for a display
564+
* detail that had nothing to do with the field's TYPE.
565+
*
566+
* Three-valued, and the absent case is deliberately NOT "grouping off":
567+
* - **absent** (default state) — the author has not judged whether this
568+
* number reads as a quantity or an identifier; the RENDERER decides.
569+
* Today that is an interim heuristic (objectui#4033, e.g. `scale: 0`
570+
* + no upper bound reads as a plain count and keeps grouping, a small
571+
* bounded integer range reads as ordinal-shaped and drops it);
572+
* eventually the locale's own default. Neither contract lives here —
573+
* this key only carries the author's EXPLICIT override when they have
574+
* one, exactly like `min`/`max`/`scale` carry constraints without
575+
* asserting what an unconstrained field means.
576+
* - **`false`** — the author's explicit opt-out: this integer is an
577+
* identifier/ordinal (year, ID, zip code, quantity meant to scan
578+
* un-grouped), never grouped regardless of what the renderer's
579+
* heuristic would have guessed.
580+
* - **`true`** — the author pins grouping ON, overriding the heuristic
581+
* the other way (a large monetary-like count that should always read
582+
* with separators even if it would otherwise be judged ordinal-shaped).
583+
*
584+
* Maps 1:1 onto `Intl.NumberFormat`'s `useGrouping` option; the console
585+
* number renderers are expected to pass it straight through. No default is
586+
* declared here on purpose — unlike `autonumberFormat`'s JSON-Schema
587+
* `default` annotation, there is no single grouping behavior every
588+
* `number` field should present until the renderer half of this contract
589+
* (objectui#4033) lands and retires the interim heuristic.
590+
*/
591+
useGrouping: z.boolean().optional().describe('Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`\'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on.'),
553592

554593
/**
555594
* Media Constraints (ADR-0104 D3 wave 2)

0 commit comments

Comments
 (0)