From 7b7880eda1cf99551b02a54232f5f6ff119cb6d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:52:20 +0000 Subject: [PATCH 1/7] feat(ui): percent cells honour percentDisplay: plain prints the percentage with no bar PercentCellRenderer reads the field's percentDisplay; the seven relay sites that copy useGrouping onto a cell's field metadata copy it too. The field form gains the percentDisplay row, its reconciliation omit row goes, and the liveness row flips planned -> live. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvqwtTGSkCm75Nci1s --- packages/spec/liveness/field.json | 9 ++++++--- packages/spec/src/data/field.form.ts | 10 ++++++++++ packages/spec/src/data/field.zod.ts | 9 +++++---- .../metadata-form-zod-reconciliation.test.ts | 10 ---------- ui/packages/fields/src/index.tsx | 14 +++++++++++++- .../plugin-detail/src/RecordDetailPanel.tsx | 3 +++ ui/packages/plugin-detail/src/RelatedList.tsx | 3 +++ ui/packages/plugin-detail/src/fieldEnrichment.ts | 3 +++ .../plugin-grid/src/objectGridGenerateColumns.tsx | 8 ++++++++ ui/packages/plugin-list/src/ObjectGallery.tsx | 3 +++ 10 files changed, 54 insertions(+), 18 deletions(-) diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index deaee0208df..0a6c2485c7f 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -272,9 +272,12 @@ "note": "[#7768] Declared 2026-08-11 as Option A of that card's fork, the narrowest shape with measured pull. Maps 1:1 to `Intl.NumberFormat`'s `useGrouping`. FLIPPED `planned` → `live` 2026-09-30, on the condition this row's earlier note set, that the objectui consumer actually read the key: the renderer half landed as objectui ae582b70a, and objectui's compare `ae582b70a...db11afd4967c` answers ahead 21, behind 0, so it is inside the console this repo pins — measured at the pin, not on objectui main. An authored boolean decides in either direction. An ABSENT key is still the renderer's call: the interim heuristic objectui#4033 introduced stays for exactly that case, and it reads the declared `scale` alone, never `min` or `max` — a declared `scale: 0` with no currency is a discrete integer, a year or an ordinal, and renders ungrouped, while any other number keeps grouping. Never `authorWarn`'d, so the flip drops no warning, and an author who set the key while it was `planned` re-authors nothing. CAVEAT — display only, and only on the surfaces whose field metadata carries the key to the cell: the seven relay sites `producer` names." }, "percentDisplay": { - "status": "planned", - "verifiedAt": "2026-10-30", - "note": "Declared spec-first on a `domain:spec` card (the spec half of a percent field's read-only face; the `domain:ui` renderer half is its own follow-up card). Measured at objectstack main 9f13ee81: ui/packages/fields/src/index.tsx#PercentCellRenderer draws the progress bar for every value and reads no key that turns it off, so nothing reads `percentDisplay` yet. Same shape as `useGrouping` when it was declared: a flat display hint, no default, not `authorWarn`'d (display only: an authored `plain` still shows the bar, and the describe says so). Flip to `live` once the console's percent face reads the key, measured under `ui/`, and drop the describe's not-yet-read clause in the same change." + "status": "live", + "verifiedAt": "2026-10-31", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/index.tsx#PercentCellRenderer reads `field.percentDisplay` after it formats the value, and the literal `plain` returns the formatted percentage alone with no `progressbar`; an absent key or `bar` keeps the bar beside the number, the renderer default the describe names. That one renderer draws a percent value on every read-only host, a list grid cell, a detail-view field and a `record:highlights` header chip, so one read covers all three. Measured in-tree at objectstack main 6285203d plus this change, where `ui/` is built with the platform", + "producer": "ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx#useObjectGridGenerateColumns copies the object definition's `percentDisplay` onto the column's field metadata beside `useGrouping` on each of its three column paths; ui/packages/plugin-detail/src/fieldEnrichment.ts#ENRICHED_FIELD_METADATA_KEYS lists it among the object-metadata keys copied onto a detail-view or header-chip field; ui/packages/plugin-detail/src/RecordDetailPanel.tsx#buildRecordDetailFields, ui/packages/plugin-detail/src/RelatedList.tsx#effectiveColumns and ui/packages/plugin-list/src/ObjectGallery.tsx#ObjectGallery relay it beside `useGrouping` the same way — seven hand-copy sites, the same seven the `useGrouping` row names. They are the producer half because the cell reads the field metadata these sites build, never the object definition: a site that dropped the key would leave an authored `plain` probability drawing its bar on that surface", + "note": "Declared spec-first on a `domain:spec` card (the spec half of a percent field's read-only face), `planned` until the renderer half read it. FLIPPED `planned` → `live` 2026-10-31 on the condition this row's earlier note set: the console's percent face reads the key, measured under `ui/`, and the describe's not-yet-read clause is dropped in the same change. Never `authorWarn`'d, so the flip drops no warning, and an author who set `plain` while it was `planned` re-authors nothing. CAVEAT — display only, and only on the surfaces whose field metadata carries the key to the cell: the seven relay sites `producer` names. The edit widget is unchanged: a percent field being edited is an input, not a face." }, "reference": { "status": "live", diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index a95419789a4..8243d3bd3b6 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -92,6 +92,16 @@ export const fieldForm = defineForm({ // groups, and the help text says so in the words of the `object.form.ts` // `userActions` row. Gated like `scale`, a `number`-only hint. { field: 'useGrouping', visibleWhen: "data.type == 'number'", helpText: 'Digit grouping (thousands separators) in displayed values. Unset: the renderer decides; an untouched switch writes nothing, so it reads off even where the renderer groups. Off: never grouped, e.g. a year or an ID. On: always grouped.' }, + // `percentDisplay` is shown once it is enforced, the `useGrouping` + // row's order: the console's percent face reads it on every read-only + // host, so declared = enforced = shown. A select over the schema's two + // members with no default: an untouched row writes nothing and the + // field keeps the renderer's default face, the bar. Gated to `percent`, + // the one type the key describes. + { field: 'percentDisplay', type: 'select', visibleWhen: "data.type == 'percent'", helpText: 'How the value is shown read-only, in list cells and on the record page. Unset: a progress bar beside the percentage. Bar: always the progress bar, for progress. Plain: the percentage alone, for a rate or a probability.', options: [ + { label: 'Bar', value: 'bar' }, + { label: 'Plain', value: 'plain' }, + ] }, // #19332 G1b (ruling 5861442317, G1) — `currencyConfig` is a strict object // with two keys, and both members of its one enum are spellable option // values, so it takes the `object.form.ts` `access` / `lifecycle` face: a diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 570bb6f57de..367116630ea 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1517,11 +1517,12 @@ export const FieldSchema = lazySchema(() => { * `25%`, at the field's `scale`), no bar. * Display only: it changes no stored value, no validation and no width. * - * Declared spec-first: the console's percent face does not read it yet (the - * renderer half is its own `domain:ui` card), so the liveness row is - * `planned` and the describe says so until that reader lands. + * Read by the console's percent face (`PercentCellRenderer` under `ui/`) on + * every read-only host: list-grid cells, the record page's fields and its + * header chips. The liveness row is `live` and names the relay sites that + * carry the key to that renderer. */ - percentDisplay: z.enum(['bar', 'plain']).optional().describe('How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does.'), + percentDisplay: z.enum(['bar', 'plain']).optional().describe('How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged.'), /** * Media Constraints (ADR-0104 D3 wave 2) diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index a30fe1ab0cf..82c68048e5e 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -375,16 +375,6 @@ const LEDGER: ReadonlyArray = [ key: 'pressed', why: "planned, not yet rendered — the reader's pressed-state predicate is validated at parse (CEL only, no boolean literal) and judged at the authoring doors in the `record` scope, but no action surface draws the pressed face until the console's header buttons read it. A form control offered today would let an author write a predicate nothing shows", }, - // #27973 — the same declared-not-enforced shape: `percentDisplay` is the spec - // half of a percent field's read-only face, `planned` in `liveness/field.json` - // until the console's percent face reads it. - { - kind: 'omit', - type: 'field', - path: ROOT_PATH, - key: 'percentDisplay', - why: "planned, not yet rendered — a percent field's read-only face (`bar` or `plain`) parses, but the console's percent face draws the progress bar for every value until it reads the key, after the spec publishes. A form control offered today would let an author pick `plain` and still see the bar; the field form gains the row with the renderer half, as `useGrouping` did once it was enforced", - }, // #27945 — a lookup's pick autofill lands spec-first the same way: `planned` // in the liveness ledger, read by no form or grid until the ui card's lookup // widget writes the declared targets on pick. diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 55a37acc9c9..42c99908393 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -974,7 +974,19 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React. const barValue = percentDisplayValue(numValue, percentScale); const formatted = formatPercent(numValue, percentScale, scale, locale); const clampedBar = Math.max(0, Math.min(100, barValue)); - + + // The field's read-only face, `FieldSchema.percentDisplay` in + // `@objectstack/spec` (objectstack#28255): `plain` is a rate or a + // probability rather than progress (an opportunity's win probability), so it + // prints the percentage alone. Absent or `bar` keeps the bar below. Only the + // literal `plain` turns the bar off: the spec refuses any other value, so + // there is no second spelling to accept here. Every host draws this one + // renderer (grid cell, detail field, header chip), and each reads the key off + // the field metadata its relay site copies from the object definition. + if (percentField.percentDisplay === 'plain') { + return {formatted}; + } + // Layout contract (objectstack#5066): THE NUMBER IS THE CONTENT, THE BAR IS // DECORATION. The bar used to be `w-16 shrink-0` while the value span was // shrinkable, so in a narrow clipping container — a `record:highlights` chip diff --git a/ui/packages/plugin-detail/src/RecordDetailPanel.tsx b/ui/packages/plugin-detail/src/RecordDetailPanel.tsx index a0aa3aec007..d093454094f 100644 --- a/ui/packages/plugin-detail/src/RecordDetailPanel.tsx +++ b/ui/packages/plugin-detail/src/RecordDetailPanel.tsx @@ -198,6 +198,9 @@ export function buildRecordDetailFields( // Beside `scale`, whose scale-0 grouping heuristic it overrides in the // number cell (objectui#11026). useGrouping: def.useGrouping, + // A percent field's read-only face: `plain` drops the percent cell's + // progress bar (objectstack#28255). + percentDisplay: (def as any).percentDisplay, format: def.format, // Served schemas key the target as `reference` (ObjectStack // convention, #2407), the only spelling read here. diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index c0d3ad3cb7e..75888d5396c 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -2061,6 +2061,9 @@ export const RelatedList: React.FC = ({ // Beside `scale`, whose scale-0 grouping heuristic it overrides in the // number cell (objectui#11026). ...(def.useGrouping !== undefined && { useGrouping: def.useGrouping }), + // A percent field's read-only face: `plain` drops the percent cell's + // progress bar in this list (objectstack#28255). + ...((def as any).percentDisplay !== undefined && { percentDisplay: (def as any).percentDisplay }), // A percent field's declared `max` is its STORAGE statement: the cell // reads it through the spec's `percentScaleOf` (a fraction unless `max` // is above 1). Dropped here, a whole-stored `50` (`max: 100`) would diff --git a/ui/packages/plugin-detail/src/fieldEnrichment.ts b/ui/packages/plugin-detail/src/fieldEnrichment.ts index 910282667aa..e412ad11a3c 100644 --- a/ui/packages/plugin-detail/src/fieldEnrichment.ts +++ b/ui/packages/plugin-detail/src/fieldEnrichment.ts @@ -225,6 +225,9 @@ export const ENRICHED_FIELD_METADATA_KEYS = [ // The author's digit-grouping hint, which overrides `scale`'s scale-0 // heuristic in the number cell (objectui#11026). 'useGrouping', + // A percent field's read-only face (`bar` or `plain`), which the percent + // cell reads to drop its progress bar (objectstack#28255). + 'percentDisplay', // Numeric range/step constraints (objectui#2572 item 3). 'min', 'max', diff --git a/ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx b/ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx index 90f02baa3e9..c8ed2d3b2d3 100644 --- a/ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx +++ b/ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx @@ -352,6 +352,10 @@ export function useObjectGridGenerateColumns({ // `scale`, its heuristic fallback: dropped here, the cell never // sees it and a `useGrouping: false` year still reads `2,026`. if (objectDefField.useGrouping !== undefined) fieldMeta.useGrouping = objectDefField.useGrouping; + // objectstack#28255 — a percent field's read-only face: dropped + // here, an authored `percentDisplay: 'plain'` probability still + // draws its progress bar in this cell. + if ((objectDefField as any).percentDisplay !== undefined) (fieldMeta as any).percentDisplay = (objectDefField as any).percentDisplay; // objectui#11475 — a percent field's declared `max` is its STORAGE // statement (`percentScaleOf`: a fraction unless `max` is above // 1), which the percent cell now reads instead of guessing from @@ -614,6 +618,8 @@ export function useObjectGridGenerateColumns({ if ((fieldDef as any).scale !== undefined) fieldMeta.scale = (fieldDef as any).scale; // Beside `scale`, as path A copies it (objectui#11026). if (fieldDef.useGrouping !== undefined) fieldMeta.useGrouping = fieldDef.useGrouping; + // The percent face, as path A copies it (objectstack#28255). + if ((fieldDef as any).percentDisplay !== undefined) (fieldMeta as any).percentDisplay = (fieldDef as any).percentDisplay; // The percent storage statement, as path A copies it (objectui#11475). if (fieldDef.max !== undefined) fieldMeta.max = fieldDef.max; if (fieldDef.format) fieldMeta.format = fieldDef.format; @@ -805,6 +811,8 @@ export function useObjectGridGenerateColumns({ if ((fieldDef as any).scale !== undefined) fieldMeta.scale = (fieldDef as any).scale; // Beside `scale`, as path A copies it (objectui#11026). if (fieldDef.useGrouping !== undefined) fieldMeta.useGrouping = fieldDef.useGrouping; + // The percent face, as path A copies it (objectstack#28255). + if ((fieldDef as any).percentDisplay !== undefined) (fieldMeta as any).percentDisplay = (fieldDef as any).percentDisplay; // The percent storage statement, as path A copies it (objectui#11475). if (fieldDef.max !== undefined) fieldMeta.max = fieldDef.max; if (fieldDef.format) fieldMeta.format = fieldDef.format; diff --git a/ui/packages/plugin-list/src/ObjectGallery.tsx b/ui/packages/plugin-list/src/ObjectGallery.tsx index 1eb7b6ca41d..482542a84d7 100644 --- a/ui/packages/plugin-list/src/ObjectGallery.tsx +++ b/ui/packages/plugin-list/src/ObjectGallery.tsx @@ -385,6 +385,9 @@ export const ObjectGallery: React.FC = (props) => { // The author's digit-grouping hint rides beside `scale`, whose scale-0 // heuristic it overrides in the number cell (objectui#11026). if (def.useGrouping !== undefined) enriched.useGrouping = def.useGrouping; + // A percent field's read-only face: `plain` drops the percent cell's + // progress bar on a card as in the grid (objectstack#28255). + if (def.percentDisplay !== undefined) enriched.percentDisplay = def.percentDisplay; // A percent field's declared `max` is its STORAGE statement: the percent // cell reads it through the spec's `percentScaleOf` (a fraction unless // `max` is above 1) instead of guessing from the value. Dropped here, a From 7a6dc7ae29c08057ae5fb7fc95bb98c8b898e46b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:56:36 +0000 Subject: [PATCH 2/7] test(ui): pin percentDisplay on the percent cell and every relay site Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvqwtTGSkCm75Nci1s --- ...CellRenderer.percentDisplay-28255.test.tsx | 66 +++++++++ .../percentDisplayRelay-28255.test.tsx | 126 ++++++++++++++++ .../gridPercentDisplay-28255.test.tsx | 136 ++++++++++++++++++ ...bjectGallery.percentDisplay-28255.test.tsx | 71 +++++++++ 4 files changed, 399 insertions(+) create mode 100644 ui/packages/fields/src/__tests__/PercentCellRenderer.percentDisplay-28255.test.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/percentDisplayRelay-28255.test.tsx create mode 100644 ui/packages/plugin-grid/src/__tests__/gridPercentDisplay-28255.test.tsx create mode 100644 ui/packages/plugin-list/src/__tests__/ObjectGallery.percentDisplay-28255.test.tsx diff --git a/ui/packages/fields/src/__tests__/PercentCellRenderer.percentDisplay-28255.test.tsx b/ui/packages/fields/src/__tests__/PercentCellRenderer.percentDisplay-28255.test.tsx new file mode 100644 index 00000000000..4be48fa1462 --- /dev/null +++ b/ui/packages/fields/src/__tests__/PercentCellRenderer.percentDisplay-28255.test.tsx @@ -0,0 +1,66 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28255: the percent cell reads the field's `percentDisplay` + * (`FieldSchema.percentDisplay` in `@objectstack/spec`). + * + * `plain` is a rate or a probability rather than progress, so the cell prints + * the percentage alone, with no `progressbar`. Absent or `bar` keeps the bar + * beside the number. Every read-only host (grid cell, detail field, header + * chip) draws this one renderer; the relay sites that carry the key to it are + * pinned in their own packages. + * + * ── Directions with the read removed (the ablation this file exists for) ── + * `plain`: no progressbar RED (the bar is drawn) + * `plain`: the number is the text GREEN (the bar's text is the same number) + * `bar` / absent: a progressbar GREEN (the default face either way) + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { PercentCellRenderer } from '../index'; + +afterEach(cleanup); + +function renderCell(value: unknown, field: Record) { + return render(); +} + +describe('PercentCellRenderer reads percentDisplay (objectstack#28255)', () => { + it('`plain` prints the percentage alone, with no progress bar', () => { + const { container } = renderCell(0.25, { percentDisplay: 'plain' }); + expect(screen.queryByRole('progressbar')).toBeNull(); + expect(container.textContent).toBe('25%'); + }); + + it('`plain` keeps the declared width and storage: a whole-stored value at scale 1', () => { + const { container } = renderCell(33.3, { percentDisplay: 'plain', max: 100, scale: 1 }); + expect(screen.queryByRole('progressbar')).toBeNull(); + expect(container.textContent).toBe('33.3%'); + }); + + it('`bar` pins the progress bar', () => { + renderCell(0.25, { percentDisplay: 'bar' }); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25'); + }); + + it('CONTROL: an absent key keeps the renderer default, the bar', () => { + const { container } = renderCell(0.25, {}); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25'); + expect(container.textContent).toBe('25%'); + }); + + it('a blank value is still the empty face under `plain`', () => { + const { container } = renderCell(null, { percentDisplay: 'plain' }); + expect(screen.queryByRole('progressbar')).toBeNull(); + expect(container.textContent).not.toContain('%'); + }); +}); diff --git a/ui/packages/plugin-detail/src/__tests__/percentDisplayRelay-28255.test.tsx b/ui/packages/plugin-detail/src/__tests__/percentDisplayRelay-28255.test.tsx new file mode 100644 index 00000000000..266d52d8308 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/percentDisplayRelay-28255.test.tsx @@ -0,0 +1,126 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28255: a percent field's authored `percentDisplay` reaches the + * DETAIL cells, through each place this package hand-copies field metadata + * beside `useGrouping`: + * + * - `ENRICHED_FIELD_METADATA_KEYS` (`enrichDetailField`), which `DetailSection` + * and `HeaderHighlight` share; + * - `buildRecordDetailFields` (`RecordDetailPanel`), rendered with NO data + * source, so the panel's own copy is the only road; + * - `RelatedList`'s cell bag. + * + * `PercentCellRenderer` reads the key; a site that drops it hands the cell a + * field without it, and a `plain` probability draws its bar again. Each case + * has a same-shape control field with no key, which must keep its bar. + * + * The viewport is pinned to desktop: `RelatedList` swaps its table for a + * gallery under `useIsMobile()` (objectui#8399). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; + +import '@object-ui/components'; +import type { DetailViewSection, HighlightField } from '@object-ui/types'; +import { DetailSection } from '../DetailSection'; +import { HeaderHighlight } from '../HeaderHighlight'; +import { RecordDetailPanel } from '../RecordDetailPanel'; +import { RelatedList } from '../RelatedList'; + +// `RecordDetailPanel` mounts `DetailView`, whose `useRecordEditable` probes +// `POST /api/v1/security/explain`; one double at module scope (objectui#7439). +vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => ({ record: { visible: true } }) })), +); + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); +afterEach(cleanup); + +const FIELDS = { + // A probability, not progress: the author asks for the plain face. + probability: { type: 'percent', label: 'Probability', percentDisplay: 'plain' }, + // The control: no key, so the bar stays. + completion: { type: 'percent', label: 'Completion' }, +}; + +const RECORD = { id: 'r1', probability: 0.25, completion: 0.6 }; + +/** Exactly one bar on the surface, and it is the control's 60. */ +function expectDeclaredFaces(container: HTMLElement) { + const text = container.textContent ?? ''; + expect(text, 'the plain value is drawn').toContain('25%'); + expect(text, 'CONTROL: the barred value is drawn').toContain('60%'); + const bars = Array.from(container.querySelectorAll('[role="progressbar"]')); + expect(bars.map((b) => b.getAttribute('aria-valuenow')), '`plain` draws no bar; the control keeps its bar').toEqual(['60']); +} + +describe('the authored percentDisplay reaches the detail cells (objectstack#28255)', () => { + it('DetailSection: through the enrichment key list, from the object schema', () => { + const section = { + title: 'S', + fields: [{ name: 'probability' }, { name: 'completion' }], + } as unknown as DetailViewSection; + const { container } = render( + , + ); + expectDeclaredFaces(container); + }); + + it('HeaderHighlight: the header chips share the enrichment key list', () => { + const fields: HighlightField[] = [ + { name: 'probability', label: 'Probability' }, + { name: 'completion', label: 'Completion' }, + ]; + const { container } = render( + , + ); + expectDeclaredFaces(container); + }); + + it('RecordDetailPanel: through the panel\'s own field build', async () => { + const { container } = render( + , + ); + await waitFor(() => expect(container.textContent).toContain('60%')); + expectDeclaredFaces(container); + }); + + it('RelatedList: through the related table\'s cell bag', async () => { + const dataSource = { + getObjectSchema: vi.fn(async () => ({ name: 'deal', fields: { name: { type: 'text', label: 'Name' }, ...FIELDS } })), + find: vi.fn(async () => ({ data: [{ ...RECORD, name: 'Tower' }], total: 1 })), + }; + const { container } = render( + , + ); + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()); + await waitFor(() => expect(container.textContent).toContain('Tower')); + expectDeclaredFaces(container.querySelector('tbody') as HTMLElement); + }); +}); diff --git a/ui/packages/plugin-grid/src/__tests__/gridPercentDisplay-28255.test.tsx b/ui/packages/plugin-grid/src/__tests__/gridPercentDisplay-28255.test.tsx new file mode 100644 index 00000000000..d0a16e2d7cb --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/gridPercentDisplay-28255.test.tsx @@ -0,0 +1,136 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28255: a percent field's authored `percentDisplay` reaches the + * grid cell on every configured-column path. + * + * `PercentCellRenderer` reads `field.percentDisplay`, but the three + * configured-column paths (ListColumn objects, a string array, inline rows + * with a `fields` projection) each build a `fieldMeta` bag key by key, the + * way `useGrouping` rides (objectui#11026). So each path is driven here. + * + * ── Directions with the cell's read in place and the grid's relay removed ── + * `plain` probability, each configured path RED (two bars per row) + * the control field (no key) GREEN (one bar either way) + * auto-generated columns (whole def, no bag) GREEN (the reference face) + */ + +import React from 'react'; +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as unknown as Element['scrollIntoView']; + } +}); + +afterEach(cleanup); + +const OBJECT = 'os_28255_deal'; + +const FIELDS: Record> = { + id: { type: 'text' }, + name: { type: 'text', label: 'Deal Name' }, + // A probability, not progress: the author asks for the plain face. + probability: { type: 'percent', label: 'Win Chance', percentDisplay: 'plain' }, + // The control: no key, so the bar stays. + completion: { type: 'percent', label: 'Completion' }, +}; + +const ROW = { id: 'd1', name: 'Tower', probability: 0.25, completion: 0.6 }; +const PERCENT_FIELDS = ['probability', 'completion'] as const; + +function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [ROW], total: 1, hasMore: false, pageSize: 50 })), + getObjectSchema: async (name: string) => ({ name, fields: structuredClone(FIELDS) }), + } as never; +} + +/** + * Render the grid and wait for the ENRICHED columns: until the object schema + * arrives a column with no type is drawn withheld (masked). The def's label, + * which differs from the humanized field name, arrives in the same pass. + */ +async function renderGrid(schemaExtra: Record) { + const ds = makeDataSource(); + const utils = render( + + + + + + + , + ); + await waitFor(() => expect(screen.getAllByText('Win Chance').length).toBeGreaterThan(0)); + return utils; +} + +/** Each body cell's text and whether it draws a progress bar. */ +function cells(container: HTMLElement): Array<{ text: string; bar: boolean }> { + return Array.from(container.querySelectorAll('tbody td')).map((td) => ({ + text: (td.textContent ?? '').trim(), + bar: td.querySelector('[role="progressbar"]') !== null, + })); +} + +function expectDeclaredFaces(container: HTMLElement) { + const all = cells(container); + const probability = all.find((c) => c.text === '25%'); + const completion = all.find((c) => c.text === '60%'); + expect(probability, `cells: ${JSON.stringify(all)}`).toBeDefined(); + expect(completion, `cells: ${JSON.stringify(all)}`).toBeDefined(); + expect(probability!.bar, '`plain` draws no bar').toBe(false); + expect(completion!.bar, 'CONTROL: no key keeps the bar').toBe(true); +} + +/** The three configured-column paths that build a `fieldMeta` bag. */ +const PATHS: Array<[string, Record]> = [ + ['ListColumn objects', { columns: [{ field: 'name' }, ...PERCENT_FIELDS.map((field) => ({ field }))] }], + ['string columns', { columns: ['name', ...PERCENT_FIELDS] }], + ['inline rows + fields projection', { fields: ['name', ...PERCENT_FIELDS] }], +]; + +describe('a percent field\'s authored percentDisplay reaches the grid cell (objectstack#28255)', () => { + it.each(PATHS)('%s: `plain` prints the percentage with no bar', async (_path, schemaExtra) => { + const { container } = await renderGrid(schemaExtra); + expectDeclaredFaces(container); + }); + + it('auto-generated columns (the whole def, no bag): the reference face', async () => { + const { container } = await renderGrid({}); + expectDeclaredFaces(container); + }); +}); diff --git a/ui/packages/plugin-list/src/__tests__/ObjectGallery.percentDisplay-28255.test.tsx b/ui/packages/plugin-list/src/__tests__/ObjectGallery.percentDisplay-28255.test.tsx new file mode 100644 index 00000000000..fb31ce629dc --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/ObjectGallery.percentDisplay-28255.test.tsx @@ -0,0 +1,71 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28255: a gallery card's percent field carries its authored + * `percentDisplay` to the shared cell renderer, as Grid and Detail do. + * + * `buildEnrichedField` copies the field def key by key; the key rides beside + * `useGrouping`. Driven through the real `ObjectGallery` render and asserted + * on what the card draws, with a same-shape control field that carries no key. + */ + +import * as React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ObjectGallery } from '../ObjectGallery'; +import { SchemaRendererProvider } from '@object-ui/react'; + +const objectSchema = { + fields: { + name: { type: 'text', label: 'Name' }, + // A probability, not progress: the author asks for the plain face. + probability: { type: 'percent', label: 'Probability', percentDisplay: 'plain' }, + // The control: no key, so the bar stays. + completion: { type: 'percent', label: 'Completion' }, + }, +}; + +const data = [{ id: 'a1', name: 'Acme Corp', probability: 0.25, completion: 0.6 }]; + +const renderGallery = () => { + const dataSource = { + find: vi.fn().mockResolvedValue(data), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(objectSchema), + }; + return render( + ['dataSource']} + > + + , + ); +}; + +describe('ObjectGallery — the authored percentDisplay reaches the card cell (objectstack#28255)', () => { + it('draws the plain probability with no bar, and the control with its bar', async () => { + const { container } = renderGallery(); + await waitFor(() => expect(screen.getByText('Acme Corp')).toBeInTheDocument()); + // CONTROL first, BY VALUE: the card drew its percent cells at all. + expect(await screen.findByText('60%')).toBeInTheDocument(); + expect(container.textContent).toContain('25%'); + const bars = Array.from(container.querySelectorAll('[role="progressbar"]')); + expect(bars.map((b) => b.getAttribute('aria-valuenow'))).toEqual(['60']); + }); +}); From d1b9987c3316a6f4da5c783fdad43ebfc4ee61a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:03:58 +0000 Subject: [PATCH 3/7] chore(spec): regenerate reference docs and liveness counts for percentDisplay Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvqwtTGSkCm75Nci1s --- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- packages/spec/liveness/state-counts/field.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 542713f20b7..089eb48c46b 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -61,7 +61,7 @@ const result = CurrencyConfigSchema.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — 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. | -| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. | | **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; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 7fbdc69ae59..02f8166d35f 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -239,7 +239,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — 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. | -| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. | | **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; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -605,7 +605,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — 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. | -| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. | | **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; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 3628ded946c..ba44b73c17c 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -73,7 +73,7 @@ Add a new field to an existing object | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — 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. | -| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. | | **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; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -506,7 +506,7 @@ Add a new field to an existing object | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — 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. | -| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. | | **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; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index e61689f1e07..458d15e5870 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 100 | 0 | 0 | 1 | 9 | 110 | +| `field` | 101 | 0 | 0 | 1 | 8 | 110 | From 24d2cd15dbbb80f58e6afacaebc75509bd797367 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:05:17 +0000 Subject: [PATCH 4/7] chore: changeset for the plain percent face Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvqwtTGSkCm75Nci1s --- .changeset/28255-percent-display-plain.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/28255-percent-display-plain.md diff --git a/.changeset/28255-percent-display-plain.md b/.changeset/28255-percent-display-plain.md new file mode 100644 index 00000000000..2386615a530 --- /dev/null +++ b/.changeset/28255-percent-display-plain.md @@ -0,0 +1,21 @@ +--- +'@object-ui/fields': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-list': patch +'@objectstack/spec': patch +--- + +feat(console): a percent field with `percentDisplay: 'plain'` shows the percentage with no progress bar + +Clause-②: no +Metadata-protocol: none + +`FieldSchema.percentDisplay` (`bar` or `plain`) was declared ahead of its renderer, so every percent field still drew a progress bar, including rates and probabilities such as an opportunity's win probability. The Console now reads it: + +- **The percent cell.** `PercentCellRenderer` in `@object-ui/fields` prints the formatted percentage alone when the field says `percentDisplay: 'plain'`. An absent key or `bar` keeps the bar beside the number, as before. The number itself is unchanged: same storage reading (`max`), same decimal places (`scale`), same locale. +- **Every read-only host.** The list grid, the record page's fields and header chips, the quick-look panel, related lists and gallery cards all draw that one cell. Each builds the cell's field metadata key by key, so each now copies `percentDisplay` beside `useGrouping`: the grid's three configured-column paths, the detail enrichment key list (record page fields and header chips), the quick-look panel, the related list and the gallery card. +- **The field form.** The field designer offers a `percentDisplay` select (Bar / Plain) on `percent` fields. Left untouched it writes nothing, and the field keeps the bar. +- **The contract.** `percentDisplay`'s describe no longer says the Studio UI does not read it, and its liveness row is `live`, naming those relay sites. + +Nothing to migrate: a field that already says `plain` now shows it. From 7faff9ec77208ab7925f5f89814ec7f430cafd01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:40:00 +0000 Subject: [PATCH 5/7] chore(i18n): field form percentDisplay row in the platform-objects catalogs Regenerated with check-i18n-bundles --write; the zh-CN, ja-JP and es-ES leaves are translated. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvqwtTGSkCm75Nci1s --- .../src/apps/translations/en.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index d801669f7dd..f35d1152eb3 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const enMetadataForms: NonNullable = { label: "Use Grouping", helpText: "Digit grouping (thousands separators) in displayed values. Unset: the renderer decides; an untouched switch writes nothing, so it reads off even where the renderer groups. Off: never grouped, e.g. a year or an ID. On: always grouped." }, + percentDisplay: { + label: "Percent Display", + helpText: "How the value is shown read-only, in list cells and on the record page. Unset: a progress bar beside the percentage. Bar: always the progress bar, for progress. Plain: the percentage alone, for a rate or a probability." + }, currencyConfig: { label: "Currency Config", helpText: "Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in every mode." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index c679a00f51a..909efbfefda 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const esESMetadataForms: NonNullable = label: "Separador de miles", helpText: "Agrupación de dígitos (separador de miles) en los valores mostrados. Sin definir: lo decide el renderizador; un interruptor que no se ha tocado no escribe nada, así que aparece desactivado aunque el renderizador agrupe. Desactivado: nunca se agrupa, p. ej., un año o un ID. Activado: siempre se agrupa." }, + percentDisplay: { + label: "Visualización del porcentaje", + helpText: "Cómo se muestra el valor en modo de solo lectura, en las celdas de lista y en la página del registro. Sin definir: una barra de progreso junto al porcentaje. Barra: siempre la barra de progreso, para un progreso. Simple: solo el porcentaje, para una tasa o una probabilidad." + }, currencyConfig: { label: "Configuración de moneda", helpText: "En qué moneda está este campo: un campo de moneda, o una fórmula cuyo tipo de retorno es Moneda. Sin definir: modo dynamic. En todos los modos el valor es un número sin más." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 218d81a44bd..95a73757ade 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const jaJPMetadataForms: NonNullable = label: "桁区切り", helpText: "表示する値の桁区切り(3 桁ごとの区切り記号)。未設定の場合はレンダラーが決めます。一度も操作していないスイッチは何も書き込まないため、レンダラーが区切る場合でもオフと表示されます。オフ:区切りません(例:年や ID)。オン:常に区切ります。" }, + percentDisplay: { + label: "パーセント表示", + helpText: "読み取り専用時の表示方法(リストのセルとレコードページ)。未設定の場合はパーセントの横に進捗バーを表示します。バー:常に進捗バーを表示します(進捗向け)。プレーン:パーセントのみを表示します(率や確率向け)。" + }, currencyConfig: { label: "通貨設定", helpText: "このフィールドの通貨(通貨フィールド、または戻り値型が「通貨」の数式フィールド)。未設定の場合は dynamic モードです。どのモードでも、値は単なる数値です。" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 11a0e69107b..acf7ecf78e1 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const zhCNMetadataForms: NonNullable = label: "千位分隔符", helpText: "显示值时的数字分组(千位分隔符)。未设置:由渲染端决定;未改动过的开关不写入任何值,所以即使渲染端会分组,它也显示为关闭。关闭:从不分组,例如年份或 ID。开启:始终分组。" }, + percentDisplay: { + label: "百分比显示", + helpText: "只读时的显示方式(列表单元格与记录页)。未设置:百分比旁显示进度条。条形:始终显示进度条,用于进度。纯数字:只显示百分比,用于比率或概率。" + }, currencyConfig: { label: "货币配置", helpText: "此字段使用哪种货币——货币字段,或返回类型为“货币”的公式字段。未设置时为 dynamic 模式。任何模式下值都是一个纯数字。" From 988c264c087ed3c02910a345f9c7cd49c8a8ca9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:13:09 +0000 Subject: [PATCH 6/7] chore(spec): regenerate reference docs after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvqwtTGSkCm75Nci1s --- content/docs/references/data/field.mdx | 12 +++++++++++- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 089eb48c46b..8701a10066e 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -31,6 +31,15 @@ const result = CurrencyConfigSchema.parse(data); | **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). | | **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own. | | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. | +| **homeAmount** | `{ field: string; rateDate: string; rateField: string }` | optional | `record` mode only, optional: where the engine stores this amount converted into the organization's home currency (the `localization.currency` setting) at write, at the rate of the record's document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. Refused under `dynamic` and `fixed`. | + +### Nested Shape: `CurrencyConfig.homeAmount` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The field on the same object that stores the amount converted into the home currency: a stored `currency` field in `dynamic` mode (it displays in `localization.currency`, so metadata never names the home currency), declaring `readonly: true` — only the engine writes it. Conventionally `AMOUNT_home`. | +| **rateDate** | `string` | ✅ | The field on the same object holding the record's document date, whose value picks the exchange rate: a `date` or `datetime` field. Required, with no default — the engine never guesses the date (not `created_at`). | +| **rateField** | `string` | ✅ | The field on the same object that stores the exchange rate the conversion used: a `number` field declaring `readonly: true` — only the engine writes it. Required: the home amount is stored with the rate used. Conventionally `AMOUNT_rate`. | --- @@ -92,7 +101,7 @@ const result = CurrencyConfigSchema.parse(data); | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -250,6 +259,7 @@ const result = CurrencyConfigSchema.parse(data); | **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). | | **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own. | | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. | +| **homeAmount** | `{ field: string; rateDate: string; rateField: string }` | optional | `record` mode only, optional: where the engine stores this amount converted into the organization's home currency (the `localization.currency` setting) at write, at the rate of the record's document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. Refused under `dynamic` and `fixed`. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 02f8166d35f..c576db257b1 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -270,7 +270,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -636,7 +636,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index ba44b73c17c..86a6ac4aaa3 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -104,7 +104,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -537,7 +537,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | From 436f05ff83549684851d609a06962b8a92c09270 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:43:02 +0000 Subject: [PATCH 7/7] test: count the field form's percentDisplay row in the catalog and predicate censuses The platform-objects translated-label census (695 -> 696) and the lint shipped-predicate census (89 -> 90, literal comparisons 64 -> 65) pin the metadata form rows; the new percent-gated row is one more of each. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvqwtTGSkCm75Nci1s --- packages/lint/src/validate-predicate-path-refs.test.ts | 9 +++++++-- .../object-lifecycle-panel-echo-decisions.test.ts | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index c92147b0087..cf2af3b85ed 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -692,7 +692,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `data.type != 'joined'` (`ReportSchema` refuses `dateRange` and // `exportOptions` on a joined report). It reaches the literal-comparison // census below. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(89); + // It is 90 today, an ADDITION of ONE: #28255 offered the field form's + // `percentDisplay` row once the console's percent face reads it, gated on + // `data.type == 'percent'`. It reaches the literal-comparison census below. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(90); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -811,7 +814,9 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // so its two `==` literal comparisons left the census. // It is 64 today: #26548's report "Period & export" section is gated on // `data.type != 'joined'`, a single-quoted literal comparison. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(64); + // It is 65 today: #28255's `field :: percentDisplay` row is gated on + // `data.type == 'percent'`, a single-quoted literal comparison. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(65); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index c11f730355b..8824ea79420 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1242,7 +1242,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // `dead`) — one row label fewer, in all three locales. // 695 since #27184: the field form's `currencyConfig.currencyField` row // (record currency mode) — one row label, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(695); + // 696 since #28255: the field form's `percentDisplay` row (a percent + // field's read-only face, bar or plain) — one row label, authored in all + // three locales. + expect(translated.length, `${locale} positive control`).toBe(696); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the