diff --git a/.changeset/number-field-use-grouping.md b/.changeset/number-field-use-grouping.md new file mode 100644 index 0000000000..73aed93e93 --- /dev/null +++ b/.changeset/number-field-use-grouping.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `Field.number` gains a `useGrouping` presentation hint (#7768) + +`scale` was the only presentation-adjacent property a `number` field carried, +and it governs decimal places, not digit grouping. Console number renderers +construct `Intl.NumberFormat` with grouping unconditionally ON, so an +ordinal/identifier integer authored as `Field.number({ scale: 0, min: 1900 })` +(e.g. a year) renders `2,026` everywhere it is shown. Downstream apps hit this +three times (hotcrm-heimao#35, #40, #59) and each time converted the field to +`Field.text` to escape the comma — trading away numeric semantics (range +validation, sort-as-number, arithmetic) for a display detail unrelated to the +field's type. + +**New:** `useGrouping?: boolean` on `FieldSchema` (flat, alongside +`precision`/`scale`/`min`/`max`), threaded automatically through +`Field.number(...)` and every other type's builder via the existing +`FieldInput` shape — no builder special-casing needed, the same way +`scale`/`min` travel today. + +Deliberately three-valued and NO default declared: + +- **absent** — the author has not judged whether this integer reads as a + quantity or an identifier; the renderer decides (an interim heuristic today, + the locale's own default eventually — that contract lives in objectui, not + here). +- **`false`** — the author's explicit opt-out: never group this number + (a year, an ID, a zip code). +- **`true`** — the author pins grouping on, overriding the heuristic the + other way. + +Maps 1:1 onto `Intl.NumberFormat`'s `useGrouping`. This is Option A of the +card's fork — the narrowest shape with measured pull — ruled on #7768, +2026-08-11, with the maintainer's veto window open. No `displayFormat` slot, +no other presentation knobs. + +Unblocks objectui#4033, the console renderer half that consumes the explicit +hint and retires the interim heuristic (explicit-hint > heuristic > +locale-default). diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 9e69804feb..c31ad9f95f 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -71,6 +71,7 @@ const result = CurrencyConfigSchema.parse(data); | **scale** | `number` | optional | Decimal places | | **min** | `number` | optional | Minimum value | | **max** | `number` | optional | Maximum value | +| **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. | | **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. | | **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. | | **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index d2e10eb556..a8d7da72d3 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -416,6 +416,7 @@ "data/Field:trackHistory", "data/Field:type", "data/Field:unique", + "data/Field:useGrouping", "data/Field:visibleWhen", "data/Field:widget", "data/FieldReference:$field", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 1400f6803a..012faa91ec 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -172,6 +172,10 @@ "status": "live", "note": "CAVEAT — grid formatting only; DDL never sizes." }, + "useGrouping": { + "status": "planned", + "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." + }, "reference": { "status": "live", "evidence": "packages/objectql/src/engine.ts:1672", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 9fac7b66cc..2648a9b2ee 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| | `object` | 49 | 0 | 0 | 1 | 50 | -| `field` | 67 | 0 | 0 | 0 | 67 | +| `field` | 67 | 0 | 0 | 1 | 68 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 0 | 44 | | `hook` | 18 | 0 | 2 | 0 | 20 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **777** | **6** | **52** | **7** | **842** | +| **total** | **777** | **6** | **52** | **8** | **843** | diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 688b4c176f..dc9fffe40d 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { FieldSchema, FieldType, @@ -239,6 +240,75 @@ describe('FieldSchema', () => { }); }); + describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => { + it('accepts an explicit `false` (author opts out of grouping — e.g. a year)', () => { + const yearField: Field = { + name: 'founded_year', + label: 'Founded Year', + type: 'number', + scale: 0, + min: 1900, + useGrouping: false, + }; + const result = FieldSchema.parse(yearField); + expect(result.useGrouping).toBe(false); + }); + + it('accepts an explicit `true` (author pins grouping on)', () => { + const result = FieldSchema.parse({ type: 'number', useGrouping: true }); + expect(result.useGrouping).toBe(true); + }); + + it('is optional — absent stays absent, no default materializes', () => { + const result = FieldSchema.parse({ type: 'number' }) as Record; + expect(result.useGrouping).toBeUndefined(); + expect('useGrouping' in result).toBe(false); + }); + + it('rejects a non-boolean value', () => { + expect(() => FieldSchema.parse({ type: 'number', useGrouping: 'true' })).toThrow(); + expect(() => FieldSchema.parse({ type: 'number', useGrouping: 1 })).toThrow(); + expect(() => FieldSchema.parse({ type: 'number', useGrouping: null })).toThrow(); + }); + + it('is not type-restricted at the schema level (flat on FieldSchema, like scale/min)', () => { + // FieldSchema does not discriminate its constraint keys by `type` — same + // posture as `scale`/`min`, which parse on any field type too. A type-aware + // "only meaningful on number/currency/percent" restriction is a renderer/lint + // concern, not a parse-time one. + expect(() => FieldSchema.parse({ type: 'text', useGrouping: false })).not.toThrow(); + }); + + it('does not disturb FieldSchema unknown-key strictness (#4001)', () => { + expect(() => FieldSchema.parse({ + type: 'number', + useGrouping: false, + totallyBogusKey: true, + } as unknown as Field)).toThrow(/Unrecognized key/); + }); + + it('Field.number(...) threads useGrouping through like scale/min (no special-casing needed)', () => { + const f = Field.number({ label: 'Founded Year', scale: 0, min: 1900, useGrouping: false }); + expect(f).toEqual({ type: 'number', label: 'Founded Year', scale: 0, min: 1900, useGrouping: false }); + expect(() => FieldSchema.parse(f)).not.toThrow(); + }); + + it('declares a boolean JSON-Schema slot with NO default — absence defers to the renderer', () => { + const js = z.toJSONSchema(FieldSchema as unknown as z.ZodType, { + unrepresentable: 'any', + io: 'input', + }) as any; + const prop = js.properties?.useGrouping; + expect(prop).toBeDefined(); + expect(prop.type).toBe('boolean'); + // Unlike `autonumberFormat`, this key carries no JSON-Schema `default` + // annotation — there is no renderer-agnostic grouping behavior to declare + // until the objectui half (#4033) retires the interim heuristic. + expect(prop.default).toBeUndefined(); + expect(js.required ?? []).not.toContain('useGrouping'); + }); + }); + describe('Select Field', () => { it('should accept select field with options', () => { const selectField: Field = { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 850a0a1dda..12310feb78 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -550,6 +550,45 @@ export const FieldSchema = lazySchema(() => strictObject({ scale: z.number().optional().describe('Decimal places'), min: z.number().optional().describe('Minimum value'), max: z.number().optional().describe('Maximum value'), + /** + * Presentation hint (#7768): whether a `number` field renders with digit + * grouping (`Intl.NumberFormat`'s `useGrouping`, e.g. `2,026` vs `2026`). + * `scale` was the ONLY presentation-adjacent property `number` had, and it + * governs decimal places, not grouping — console renderers construct + * `Intl.NumberFormat` with grouping unconditionally ON, so an + * ordinal/identifier integer stored as `Field.number({ scale: 0, min: 1900 + * })` (a year) renders `2,026` everywhere it is shown. Downstream apps hit + * this three times (hotcrm-heimao#35/#40/#59) and each time converted the + * field to `Field.text` to escape the comma — trading away numeric + * semantics (range validation, sort-as-number, arithmetic) for a display + * detail that had nothing to do with the field's TYPE. + * + * Three-valued, and the absent case is deliberately NOT "grouping off": + * - **absent** (default state) — the author has not judged whether this + * number reads as a quantity or an identifier; the RENDERER decides. + * Today that is an interim heuristic (objectui#4033, e.g. `scale: 0` + * + no upper bound reads as a plain count and keeps grouping, a small + * bounded integer range reads as ordinal-shaped and drops it); + * eventually the locale's own default. Neither contract lives here — + * this key only carries the author's EXPLICIT override when they have + * one, exactly like `min`/`max`/`scale` carry constraints without + * asserting what an unconstrained field means. + * - **`false`** — the author's explicit opt-out: this integer is an + * identifier/ordinal (year, ID, zip code, quantity meant to scan + * un-grouped), never grouped regardless of what the renderer's + * heuristic would have guessed. + * - **`true`** — the author pins grouping ON, overriding the heuristic + * the other way (a large monetary-like count that should always read + * with separators even if it would otherwise be judged ordinal-shaped). + * + * Maps 1:1 onto `Intl.NumberFormat`'s `useGrouping` option; the console + * number renderers are expected to pass it straight through. No default is + * declared here on purpose — unlike `autonumberFormat`'s JSON-Schema + * `default` annotation, there is no single grouping behavior every + * `number` field should present until the renderer half of this contract + * (objectui#4033) lands and retires the interim heuristic. + */ + 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.'), /** * Media Constraints (ADR-0104 D3 wave 2)