From 74cf402ed09e2652a45380b3813348f750987def Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 10:26:51 +0000 Subject: [PATCH 1/5] feat(types): declare the spec's axis config list on ObjectChartSchema.yAxis (objectui#10518) `ObjectChartSchema.yAxis` is `@objectstack/spec`'s `ChartAxisSchema[]` by reference on the Zod mirror and `ChartAxis[]` on the TS twin, with the objectui#8317 import boundary. A single axis object or a bare column name is refused, as on `ChartSchema.yAxis`. The two boundary ledgers record the new crossing; the pin is object-chart-axis-config-10518.test.ts. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../10518-object-chart-y-axis-declared.md | 32 +++ .../__tests__/imported-defaults-8317.test.ts | 2 + .../object-chart-axis-config-10518.test.ts | 267 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 11 +- packages/types/src/objectql.ts | 43 +++ packages/types/src/zod/objectql.zod.ts | 50 ++++ 6 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 .changeset/10518-object-chart-y-axis-declared.md create mode 100644 packages/types/src/__tests__/object-chart-axis-config-10518.test.ts diff --git a/.changeset/10518-object-chart-y-axis-declared.md b/.changeset/10518-object-chart-y-axis-declared.md new file mode 100644 index 0000000000..aedcda64ca --- /dev/null +++ b/.changeset/10518-object-chart-y-axis-declared.md @@ -0,0 +1,32 @@ +--- +'@object-ui/types': minor +--- + +`ObjectChartSchema` (and its TS twin) declares `yAxis` as `@objectstack/spec`'s axis config list, +`ChartAxisSchema[]`. The Zod mirror references the spec schema, and the TS side is typed by the +spec's `ChartAxis`. Until now the key rode `BaseSchema`'s passthrough unchecked (objectui#10518, +ruling 5809510046, branch 2, declare). This is the `object-chart` sibling of the objectui#7690 +declaration on `ChartSchema`. + +- The spec declares the key on this node. The `REACT_BLOCKS` entry for `` has + `schemaType: 'object-chart'` and `schema: ChartConfigSchema`, and lists `yAxis` in its + `dataProps`. +- A real producer writes it: the objectstack showcase command-center page's dataset-bound charts + author `yAxis: [{ field, stepSize: 1 }]`. That document still parses, unchanged. +- The spec's `.default()`s are not written into the parse output. An omitted `showGridLines` or + `logarithmic` stays omitted. + +⚠️ Shipped as `minor`, not `patch`: documents that validated before now **refuse** (the accept set +narrows), and each of them draws a chart today: + +- A `yAxis` entry with a malformed value (`stepSize: 'big'`, `position: 'middle'`), an undeclared + key (`logScale`, `grid`) or no `field`. The renderer ignores what it cannot read and draws the + rest. +- A `yAxis` written as a single object or a bare column name. The renderer's normalizer honours + both as a tolerance. Neither is a member of the spec's list, and no producer on this node writes + either, so they are refused (the reading objectui#7690 applied to `ChartSchema.yAxis`). The + normalizer is unchanged. + +`xAxis` on this node is not declared by this change. It stays open on objectui#10518. + +No renderer changed. diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 25bd06e287..ee3af659a1 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -196,6 +196,8 @@ const IMPORTED: Array = [ // objectui#7690: `ChartSchema.xAxis` / `ChartSchema.yAxis` reference the // spec's axis config object, whose `showGridLines` / `logarithmic` defaults // are exactly what this boundary exists to keep out of a parse output. + // objectui#10518: `ObjectChartSchema.yAxis` crosses the same boundary with + // the same symbol, so this one entry measures both crossings. ['ChartAxisSchema', SpecChartAxisSchema], ] as const; diff --git a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts new file mode 100644 index 0000000000..5bb0d8ed4c --- /dev/null +++ b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts @@ -0,0 +1,267 @@ +/** + * 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. + */ + +/** + * objectui#10518 — `ObjectChartSchema.yAxis` DECLARES the spec's axis config + * LIST (ruling 5809510046, branch 2 — declare), the `object-chart` sibling of + * objectui#7690, whose pin for the `chart` node is + * `chart-axis-config-7690.test.ts`. + * + * ## The protocol answered first + * + * `@objectstack/spec/ui` declares the key on this very node: the `REACT_BLOCKS` + * entry for `` carries `schemaType: 'object-chart'` and + * `schema: ChartConfigSchema`, and lists `yAxis` among its `dataProps`; + * `ChartConfigSchema.yAxis` is an ARRAY of `ChartAxisSchema`. Block (a) reads + * both off the installed spec rather than writing them down here. + * + * ## The defect (measured on the branch point before the change) + * + * The key was declared on neither face, so it rode `BaseSchema`'s + * `.passthrough()` — KEPT, read by the renderer, and UNCHECKED: + * `yAxis: [{ field: 'n', stepSize: 'big' }]`, an entry carrying `logScale`, + * and a single axis object all parsed green. The shape pin in (a) and every + * refusal in (c) and (d) are red on that tree; the PR records the run. + * + * ## The lit control is the real producer + * + * The objectstack showcase command-center page builds its dataset-bound charts + * through one helper that writes `yAxis: [{ field: values[0], stepSize: 1 }]` + * inside the node's `properties` bag. `SchemaRenderer` hoists that bag onto the + * node, so the node below is the shape the mirror judges — flattened, with the + * helper's own keys. It parses on the branch point and on this change alike: + * the declaration narrows nothing that producer writes. + * + * ## How refusals are asserted + * + * By issue `code` and `path`. Where a message is asserted, it is compared + * against the SPEC's own message for the same axis, read off the spec schema in + * the same test — never against a string written here. + */ + +import { describe, it, expect } from 'vitest'; +import { + ChartAxisSchema as SpecChartAxisSchema, + ChartConfigSchema as SpecChartConfigSchema, + REACT_BLOCKS, + type ChartAxis as SpecChartAxis, +} from '@objectstack/spec/ui'; +import type { ObjectChartSchema } from '../objectql'; +import { ObjectChartSchema as ObjectChartMirror } from '../zod/objectql.zod'; +import { safeValidateSchema } from '../zod/index.zod'; + +const CHART = { type: 'object-chart', chartType: 'bar' } as const; +const chart = (extra: Record) => ({ ...CHART, ...extra }); + +const parsed = (doc: unknown) => { + const r = ObjectChartMirror.safeParse(doc); + if (!r.success) throw new Error(`expected ACCEPT, got: ${JSON.stringify(r.error.issues)}`); + return r.data as Record; +}; + +const issues = (doc: unknown) => { + const r = ObjectChartMirror.safeParse(doc); + if (r.success) throw new Error('expected REFUSE, got ACCEPT'); + return r.error.issues; +}; + +/** The spec's own first message for an axis value, read live off the spec schema. */ +const specMessage = (axis: unknown): string => { + const r = SpecChartAxisSchema.safeParse(axis); + if (r.success) throw new Error('control: the spec accepts this axis'); + return r.error.issues[0]!.message; +}; + +const SPEC_AXIS_KEYS = Object.keys(SpecChartAxisSchema.shape).sort(); + +/** One valid value per spec axis key — a key the spec adds without a sample here fails (a) loudly. */ +const FULL_AXIS: Record = { + field: 'task_count', + title: { en: 'Tasks', 'zh-CN': '任务' }, + format: '0,0', + min: 0, + max: 100, + stepSize: 1, + showGridLines: false, + position: 'left', + logarithmic: false, +}; + +/** + * The showcase command-center producer's node, flattened as `SchemaRenderer` + * hoists `properties` — the `chart()` helper with its `integerYAxis` flag on. + */ +const SHOWCASE_NODE = { + id: 'cc_status_c', + type: 'object-chart', + responsiveStyles: { large: { width: '100%', minWidth: '0' } }, + dataset: 'showcase_task_metrics', + dimensions: ['status'], + values: ['task_count'], + chartType: 'bar', + colors: ['hsl(192 86% 46%)', 'hsl(256 72% 62%)'], + yAxis: [{ field: 'task_count', stepSize: 1 }], +} as const; + +type Shape = Record; +const mirrorShape = (ObjectChartMirror as unknown as { shape: Shape }).shape; + +/* ── Type-level pin (compiled by `tsc -p tsconfig.test.json`) ─────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** + * `Equal`, not `extends`: an UNDECLARED member reads `any` through + * `BaseSchema`'s index signature, and a one-way check accepts `any` — which is + * precisely the before-state. + */ +export type assertionYAxisIsTheSpecList = Expect>; +/** The helper can FAIL — synthetic control (an undeclared key reads `any`). */ +export type assertionEqualCanFail = Expect, false>>; + +/* ── (a) the declaration IS the spec's ───────────────────────────────────── */ + +describe('objectui#10518 (a) — `yAxis` is declared, with the spec axis list', () => { + it('the spec declares `yAxis` on the `object-chart` react block, as `ChartConfigSchema`\'s list', () => { + const block = REACT_BLOCKS.find((b) => b.schemaType === 'object-chart'); + expect(block, 'no REACT_BLOCKS entry for object-chart in the installed spec').toBeDefined(); + expect(block!.schema).toBe(SpecChartConfigSchema); + expect(block!.dataProps).toContain('yAxis'); + // The list arm, read off the spec: one entry parses, a bare object is + // refused AT `yAxis` (the spec's config needs its own `type`, so it is given). + expect(SpecChartConfigSchema.safeParse({ type: 'bar', yAxis: [{ field: 'n' }] }).success).toBe(true); + const single = SpecChartConfigSchema.safeParse({ type: 'bar', yAxis: { field: 'n' } }); + expect(single.success).toBe(false); + expect(single.error!.issues.map((i) => i.path)).toEqual([['yAxis']]); + }); + + it('`yAxis` is in the mirror\'s own shape — declared, not passed through', () => { + expect(Object.keys(mirrorShape)).toContain('yAxis'); + }); + + it('the sample below covers exactly the spec\'s axis keys (read off the installed spec)', () => { + expect(Object.keys(FULL_AXIS).sort()).toEqual(SPEC_AXIS_KEYS); + }); + + it('every spec axis key is accepted on a `yAxis` entry, and the value survives unchanged', () => { + const second = { ...FULL_AXIS, position: 'right' }; + expect(parsed(chart({ yAxis: [FULL_AXIS, second] })).yAxis).toEqual([FULL_AXIS, second]); + }); + + /* + * The spec gives `showGridLines` and `logarithmic` a `.default()`. Referenced + * raw, a parse would WRITE both into the output. The CONTROL is the spec + * schema itself, which does inject — so the equality is a reading of + * `stripImportedDefaults`, not an accident. + */ + it('omitted keys stay omitted — the spec defaults are not written into the output', () => { + expect(parsed(chart({ yAxis: [{ field: 'task_count' }] })).yAxis).toEqual([{ field: 'task_count' }]); + }); + + it('CONTROL — the spec schema referenced raw DOES inject its defaults', () => { + expect(SpecChartAxisSchema.parse({ field: 'task_count' })).toEqual({ + field: 'task_count', + showGridLines: true, + logarithmic: false, + }); + }); +}); + +/* ── (b) the lit control: the real producer ──────────────────────────────── */ + +describe('objectui#10518 (b) — LIT CONTROL: the showcase producer\'s node parses, unchanged', () => { + it('on the mirror, with `yAxis` exactly as written', () => { + expect(parsed(SHOWCASE_NODE).yAxis).toEqual([{ field: 'task_count', stepSize: 1 }]); + }); + + it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => { + expect(safeValidateSchema(SHOWCASE_NODE).success).toBe(true); + }); +}); + +/* ── (c) values and keys are CHECKED now ─────────────────────────────────── */ + +describe('objectui#10518 (c) — a malformed `yAxis` entry is refused at its own path', () => { + it.each([ + ['a non-number `stepSize`', { field: 'task_count', stepSize: 'big' }, ['yAxis', 0, 'stepSize'], 'invalid_type'], + ['a `position` outside the four', { field: 'task_count', position: 'middle' }, ['yAxis', 0, 'position'], 'invalid_value'], + ['an undeclared key the spec names an alias of (`logScale`)', { field: 'task_count', logScale: true }, ['yAxis', 0], 'unrecognized_keys'], + ['a missing `field` — the spec requires it', { stepSize: 1 }, ['yAxis', 0, 'field'], 'invalid_type'], + ])('%s', (_label, axis, path, code) => { + const [issue] = issues(chart({ yAxis: [axis] })); + expect(issue!.code).toBe(code); + expect(issue!.path).toEqual(path); + expect(issue!.message).toBe(specMessage(axis)); + }); + + it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => { + expect(safeValidateSchema(chart({ yAxis: [{ field: 'task_count', stepSize: 'big' }] })).success).toBe(false); + }); + + /* CONTROL for every refusal above: the same documents minus the defect parse. */ + it('CONTROL — the same entries, minus the defect, are accepted', () => { + expect(() => parsed(chart({ yAxis: [{ field: 'task_count', stepSize: 2 }] }))).not.toThrow(); + expect(() => parsed(chart({ yAxis: [{ field: 'task_count', position: 'right', logarithmic: true }] }))).not.toThrow(); + expect(safeValidateSchema(chart({ yAxis: [{ field: 'task_count', stepSize: 2 }] })).success).toBe(true); + }); +}); + +/* ── (d) `yAxis` is the spec's LIST — the two other shapes the normalizer tolerates are not members ── */ + +describe('objectui#10518 (d) — `yAxis` is the spec array, and only that', () => { + it.each([ + ['a single axis object', { field: 'task_count', stepSize: 1 }], + ['a bare column name', 'task_count'], + ])('%s is refused at `yAxis` with `invalid_type`', (_label, yAxis) => { + const [issue] = issues(chart({ yAxis })); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['yAxis']); + }); + + it('CONTROL — the same axis as a one-entry list parses', () => { + expect(parsed(chart({ yAxis: [{ field: 'task_count', stepSize: 1 }] })).yAxis).toEqual([ + { field: 'task_count', stepSize: 1 }, + ]); + }); +}); + +/* ── (e) the TS twin carries the same contract ───────────────────────────── */ + +describe('objectui#10518 (e) — the TS twin types `yAxis` with the spec\'s `ChartAxis[]`', () => { + it('accepts the spec shape at the authoring site', () => { + const axis: SpecChartAxis = { field: 'task_count', stepSize: 1, title: { en: 'Tasks', 'zh-CN': '任务' } }; + const node: ObjectChartSchema = { + type: 'object-chart', + chartType: 'bar', + dataset: 'showcase_task_metrics', + dimensions: ['status'], + values: ['task_count'], + yAxis: [axis, { field: 'task_count', position: 'right' }], + }; + expect(node.yAxis).toHaveLength(2); + }); + + it('refuses what the mirror refuses — checked by `tsc -p tsconfig.test.json`', () => { + // Not `as const`: a readonly literal would fail every line below for a + // reason of its own, and each `@ts-expect-error` would pass without testing. + const base = { type: 'object-chart' as const, chartType: 'bar' as const }; + const refused: ObjectChartSchema[] = [ + // @ts-expect-error — `yAxis` is the spec's LIST, not a single axis object + { ...base, yAxis: { field: 'task_count' } }, + // @ts-expect-error — `yAxis` is the spec's LIST, not a bare column name + { ...base, yAxis: 'task_count' }, + // @ts-expect-error — `stepSize` is a number + { ...base, yAxis: [{ field: 'task_count', stepSize: 'big' }] }, + // @ts-expect-error — `field` is required on a spec axis object + { ...base, yAxis: [{ stepSize: 1 }] }, + ]; + expect(refused).toHaveLength(4); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index e480cc1903..ea4180492a 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4148,9 +4148,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'data-display.zod.ts#ChartSchema', 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', - // ⭐ ONE entry, FOUR spec crossings — two cards put them there and both grounds - // are recorded, because either one alone is enough to keep this membership and - // deleting the entry needs both to be gone. + // ⭐ ONE entry, FIVE spec crossings — three cards put them there and every + // ground is recorded, because any one alone is enough to keep this membership + // and deleting the entry needs all of them to be gone. // - objectui#7946 (rework round): `aggregate` is `SpecChartAggregateSchema` by // reference rather than the local near-copy the first cut declared, so a // spec bump that widens or narrows the object-bound aggregation vocabulary @@ -4162,6 +4162,11 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // own declaration: `DashboardRenderer` forwards `widget.compareTo` // verbatim). So a spec bump that moves the chart drill vocabulary, the // i18n label union, or the widget's comparison directive moves ONE side too. + // - objectui#10518: `yAxis` is the spec's `ChartAxisSchema` by reference — + // the axis config LIST `ChartConfigSchema.yAxis` declares, and the spec's + // `` react block (`schemaType: 'object-chart'`) publishes — so + // a spec bump that moves the axis vocabulary moves ONE side of this pair, + // as it does for `data-display.zod.ts#ChartSchema` above. // Either way it is exactly what this list exists to make legible rather than // mysterious. // objectui#8651: the `calendar` CONTAINER is the spec's own diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index efe2595f4a..a54ad6d1ce 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -139,6 +139,9 @@ import type { ChartDrillDown, I18nLabel, DashboardWidget as SpecDashboardWidget, + // objectui#10518 — the spec's axis config object, the element type of + // `ObjectChartSchema.yAxis`. Aliased for the reason `SpecGanttConfig` above is. + ChartAxis as SpecChartAxis, } from '@objectstack/spec/ui'; /** @@ -4245,6 +4248,16 @@ export type KanbanConditionalFormattingRule = * did not matter: each ledgers the OTHER card's keys by name and asserts only * that each is STILL READ, never that it is still undeclared. So declaring a * ledgered key does not redden its ledger — dropping the READ does. + * + * ## A third card, and a channel neither census sees (objectui#10518) + * + * `yAxis` (see the member) is read by NO `schema.KEY` expression in + * `ObjectChart.tsx`: the component spreads the node into the schema it hands + * `ChartRenderer`, and `normalizeChartSchema` reads it there. Both census pins + * above read `schema.KEY` off that one file, so neither lists it — as a read or + * as an exception — and neither was moved. Its pin is + * `__tests__/object-chart-axis-config-10518.test.ts`; the render path was + * measured once with a real registry probe, recorded on the card's PR. */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; @@ -4489,6 +4502,36 @@ export interface ObjectChartSchema extends BaseSchema { * value check that `.passthrough()` was skipping. */ compareTo?: SpecDashboardWidget['compareTo']; + /** + * AUTHORABLE — the value (y) axes: an ARRAY of `@objectstack/spec`'s axis + * CONFIG objects, the type of `ChartConfigSchema.yAxis`. The first entry is + * the primary axis; a second entry declares the right-hand axis a series + * binds to. + * + * Declared by objectui#10518, the `object-chart` sibling of objectui#7690 + * (ruling 5809510046, branch 2 — declare), for three measured reasons: + * + * - the spec declares the key ON THIS NODE: its `REACT_BLOCKS` entry for + * `` carries `schemaType: 'object-chart'` and + * `schema: ChartConfigSchema`, and lists `yAxis` among its `dataProps`; + * - the node renders it: `ObjectChart` spreads the node into the schema it + * hands `ChartRenderer`, and `normalizeChartSchema` reads every spec axis + * key off each entry — so the type IS the spec's `ChartAxis`, not a + * restatement of it; + * - a real producer writes it: the objectstack showcase command-center + * page's dataset-bound `object-chart` authors + * `yAxis: [{ field, stepSize: 1 }]` to pin integer ticks on a count axis. + * + * Until then the list survived only on `BaseSchema`'s index signature: read + * by the renderer, checked by nothing. Only the spec's list is a member — a + * single axis object or a bare column name, which the normalizer tolerates, + * is refused by the mirror and does not compile here. + * + * ⚠️ Declared beside {@link yAxisFields} above, a separate legacy + * vocabulary. Whether that key is live on this node is reported on + * objectui#10518 for its own ruling; this member settles nothing about it. + */ + yAxis?: SpecChartAxis[]; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 2176166f03..ce6da55f42 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -38,6 +38,7 @@ import { ChartDrillDownSchema as SpecChartDrillDownSchema, I18nLabelSchema as SpecI18nLabelSchema, DashboardWidgetSchema as SpecDashboardWidgetSchema, + ChartAxisSchema as SpecChartAxisSchema, UserFilterFieldSchema as SpecUserFilterFieldSchema, checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; @@ -2393,6 +2394,18 @@ export const ObjectKanbanSchema = BaseSchema.extend({ onQuickAdd: handlerKeyRefusal('onQuickAdd', 'runtime-slot', 'Quick Add handler'), }).superRefine(requireKanbanRecordSource); +/** + * The message for an `ObjectChartSchema.yAxis` that is not a list + * (objectui#10518) — the remedy names the spec's own spelling, the one-entry + * list the showcase producer writes. Applied to the array's own `invalid_type` + * only, so an issue INSIDE an entry keeps the spec's wording (including its + * alias hint, e.g. `logScale` → `logarithmic`). + */ +const OBJECT_CHART_Y_AXIS_IS_A_LIST_GUIDANCE = + '`yAxis` on an `object-chart` is `@objectstack/spec`\'s ARRAY of axis objects — write ' + + '`yAxis: [{ field: \'task_count\', stepSize: 1 }]`, one entry per value axis (a second entry declares ' + + 'the right-hand axis). A single axis object or a bare column name is not a member of the protocol.'; + /** * ObjectChart Schema */ @@ -2496,6 +2509,43 @@ export const ObjectChartSchema = BaseSchema.extend({ // the position it reads (`stripImportedDefaults()`). compareTo: stripImportedDefaults(SpecDashboardWidgetSchema).shape.compareTo .describe('Period-over-period comparison directive, forwarded verbatim from the dashboard widget key of the same name — bound BY REFERENCE to `DashboardWidgetSchema.shape.compareTo` so the producer and this consumer cannot drift into two dialects.'), + // ── objectui#10518: the value axes, as `@objectstack/spec`'s axis config + // LIST — the `object-chart` sibling of objectui#7690, under the same ruling + // (5809510046, branch 2 — declare), which triage reused for this node. + // + // Three readings, each taken rather than assumed (the TS twin in + // `../objectql.ts` carries them in full): + // - the spec DECLARES the key on this node: its `REACT_BLOCKS` entry for + // `` has `schemaType: 'object-chart'` and + // `schema: ChartConfigSchema`, and lists `yAxis` among its `dataProps`; + // `ChartConfigSchema.yAxis` is an ARRAY of `ChartAxisSchema`; + // - the node RENDERS it: `ObjectChart` spreads the node into the schema it + // hands `ChartRenderer`, whose `normalizeChartSchema` reads the spec's + // axis keys (tied to them from the renderer's side by + // `normalizeChartSchema.specAxisKeys-7690.test.ts`); + // - a real producer WRITES it: the objectstack showcase command-center + // page's dataset-bound `object-chart` authors `yAxis: [{ field, stepSize: 1 }]` + // to pin integer ticks on a count axis. + // + // Until here the key rode `BaseSchema`'s `.passthrough()` — kept, read and + // UNCHECKED — so `yAxis: [{ field: 'n', stepSize: 'big' }]` parsed green. + // + // BY REFERENCE, not restated: the key set, the value domains and the strict + // refusal are the spec's. `stripImportedDefaults` keeps the spec's two + // `.default()`s out of the parse output, exactly as on `ChartSchema.yAxis`. + // Only the spec's LIST is a member: the single object and the bare column + // name `normalizeChartSchema` tolerates are refused (the liveness read on + // objectui#10518, a one-time reading, found no producer on this node writing + // either). + yAxis: z + .array(stripImportedDefaults(SpecChartAxisSchema), { + error: (issue) => (issue.code === 'invalid_type' ? OBJECT_CHART_Y_AXIS_IS_A_LIST_GUIDANCE : undefined), + }) + .optional() + .describe( + 'AUTHORABLE — value (y) axes: an ARRAY of @objectstack/spec ChartAxis objects, by reference (field required, strict). ' + + 'The first entry is the primary axis; a second entry declares the right-hand axis.', + ), }); /** From 0b1b1c81681c2a62df2ebaed52c55f90122fadb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 11:30:24 +0000 Subject: [PATCH 2/5] feat(types): declare the spec's axis config object on ObjectChartSchema.xAxis (objectui#10518) `ObjectChartSchema.xAxis` is ONE `@objectstack/spec` `ChartAxisSchema` by reference on the Zod mirror and `ChartAxis` on the TS twin, with the objectui#8317 import boundary. There is no string arm and no fold (seat decision, option A): a bare column name or a list is refused with the `xAxis: { field }` remedy, and a malformed object is refused at `xAxis` with the spec's own diagnostic. The one typed renderer-tolerance fixture that wrote a bare string takes an explicit cast, and the renderer is unchanged. The pin, both ledger grounds and the changeset now cover `xAxis`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../10518-object-chart-y-axis-declared.md | 42 ++-- ...rt.absentCategoryAxisRefusal-8168.test.tsx | 2 +- .../__tests__/imported-defaults-8317.test.ts | 5 +- .../object-chart-axis-config-10518.test.ts | 202 ++++++++++++++++-- .../src/__tests__/zod-mirror-parity.test.ts | 16 +- packages/types/src/objectql.ts | 40 +++- packages/types/src/zod/objectql.zod.ts | 96 +++++++-- 7 files changed, 331 insertions(+), 72 deletions(-) diff --git a/.changeset/10518-object-chart-y-axis-declared.md b/.changeset/10518-object-chart-y-axis-declared.md index aedcda64ca..57764a4c6c 100644 --- a/.changeset/10518-object-chart-y-axis-declared.md +++ b/.changeset/10518-object-chart-y-axis-declared.md @@ -2,31 +2,43 @@ '@object-ui/types': minor --- -`ObjectChartSchema` (and its TS twin) declares `yAxis` as `@objectstack/spec`'s axis config list, -`ChartAxisSchema[]`. The Zod mirror references the spec schema, and the TS side is typed by the -spec's `ChartAxis`. Until now the key rode `BaseSchema`'s passthrough unchecked (objectui#10518, -ruling 5809510046, branch 2, declare). This is the `object-chart` sibling of the objectui#7690 -declaration on `ChartSchema`. +`ObjectChartSchema` (and its TS twin) declares `xAxis` and `yAxis` as `@objectstack/spec`'s axis +config: `xAxis` is ONE `ChartAxisSchema` object and `yAxis` a list of them. The Zod mirror references +the spec schema, and the TS side is typed by the spec's `ChartAxis`. Until now both keys rode +`BaseSchema`'s passthrough unchecked (objectui#10518, ruling 5809510046, branch 2, declare). This is +the `object-chart` sibling of the objectui#7690 declaration on `ChartSchema`. -- The spec declares the key on this node. The `REACT_BLOCKS` entry for `` has - `schemaType: 'object-chart'` and `schema: ChartConfigSchema`, and lists `yAxis` in its - `dataProps`. -- A real producer writes it: the objectstack showcase command-center page's dataset-bound charts - author `yAxis: [{ field, stepSize: 1 }]`. That document still parses, unchanged. +- The spec declares both keys on this node. The `REACT_BLOCKS` entry for the `ObjectChart` react + block has `schemaType: 'object-chart'` and `schema: ChartConfigSchema`, and lists `xAxis` and + `yAxis` in its `dataProps`. +- Real producers write them. The objectstack showcase command-center page's dataset-bound charts + author `yAxis: [{ field, stepSize: 1 }]`, and its renewals-pipeline page writes + `xAxis: { field }` with `yAxis: [{ field, format }]`. Both still parse, unchanged. - The spec's `.default()`s are not written into the parse output. An omitted `showGridLines` or `logarithmic` stays omitted. +- `xAxis` is the object ONLY. There is no string arm and no fold onto `xAxisKey`: the bare-string + alias of objectui#7113 belongs to the `chart` node alone, and the spec's `ChartConfigSchema.xAxis` + has no string arm (seat decision on objectui#10518, option A). ⚠️ Shipped as `minor`, not `patch`: documents that validated before now **refuse** (the accept set narrows), and each of them draws a chart today: +- An `xAxis` object with a malformed value (`min: 'zero'`, `position: 'middle'`), an undeclared + key (`grid`, `logScale`) or no `field`. It is refused as one issue at `xAxis` whose message carries + the spec's own diagnostic, including its alias hint. +- An `xAxis` written as a bare column name (`xAxis: 'status'`), a list of axis objects, or any other + non-object value. It is refused with the remedy `xAxis: { field }`. The renderer's normalizer still + honours a bare string as a tolerance, and that is unchanged, but no producer on this node writes + one. - A `yAxis` entry with a malformed value (`stepSize: 'big'`, `position: 'middle'`), an undeclared - key (`logScale`, `grid`) or no `field`. The renderer ignores what it cannot read and draws the - rest. + key (`logScale`, `grid`) or no `field`. It is refused at the entry's own path. The renderer ignores + what it cannot read and draws the rest. - A `yAxis` written as a single object or a bare column name. The renderer's normalizer honours both as a tolerance. Neither is a member of the spec's list, and no producer on this node writes - either, so they are refused (the reading objectui#7690 applied to `ChartSchema.yAxis`). The - normalizer is unchanged. + either, so they are refused with a remedy (the reading objectui#7690 applied to + `ChartSchema.yAxis`). The normalizer is unchanged. -`xAxis` on this node is not declared by this change. It stays open on objectui#10518. +A TS consumer that typed a bare-string `xAxis` onto an `ObjectChartSchema` value no longer compiles. +The one in-repo instance, a renderer-tolerance test fixture, now carries an explicit cast. No renderer changed. diff --git a/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx b/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx index 2b790979d6..7b935f8957 100644 --- a/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx @@ -328,7 +328,7 @@ describe('objectui#8168 — what the refusal deliberately does not touch', () => it('a bare string `xAxis`, the report surface spelling', async () => { renderChart({ objectName: 'crm_opportunity', - xAxis: 'stage', + xAxis: 'stage' as unknown as ObjectChartSchema['xAxis'], // unauthorable since objectui#10518 (object only): the renderer's tolerance, cast as `UNAUTHORABLE` casts `aggregate` series: [{ dataKey: 'amount' }], }); await expectNoRefusal(); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index ee3af659a1..09c2f22f5f 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -196,8 +196,9 @@ const IMPORTED: Array = [ // objectui#7690: `ChartSchema.xAxis` / `ChartSchema.yAxis` reference the // spec's axis config object, whose `showGridLines` / `logarithmic` defaults // are exactly what this boundary exists to keep out of a parse output. - // objectui#10518: `ObjectChartSchema.yAxis` crosses the same boundary with - // the same symbol, so this one entry measures both crossings. + // objectui#10518: `ObjectChartSchema.xAxis` / `ObjectChartSchema.yAxis` + // cross the same boundary with the same symbol, so this one entry measures + // all four crossings. ['ChartAxisSchema', SpecChartAxisSchema], ] as const; diff --git a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts index 5bb0d8ed4c..64c570cfa7 100644 --- a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts +++ b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts @@ -7,41 +7,57 @@ */ /** - * objectui#10518 — `ObjectChartSchema.yAxis` DECLARES the spec's axis config - * LIST (ruling 5809510046, branch 2 — declare), the `object-chart` sibling of + * objectui#10518 — `ObjectChartSchema.xAxis` / `ObjectChartSchema.yAxis` + * DECLARE the spec's axis config — ONE object on `xAxis`, a LIST on `yAxis` + * (ruling 5809510046, branch 2 — declare), the `object-chart` sibling of * objectui#7690, whose pin for the `chart` node is * `chart-axis-config-7690.test.ts`. * * ## The protocol answered first * - * `@objectstack/spec/ui` declares the key on this very node: the `REACT_BLOCKS` - * entry for `` carries `schemaType: 'object-chart'` and - * `schema: ChartConfigSchema`, and lists `yAxis` among its `dataProps`; - * `ChartConfigSchema.yAxis` is an ARRAY of `ChartAxisSchema`. Block (a) reads - * both off the installed spec rather than writing them down here. + * `@objectstack/spec/ui` declares both keys on this very node: the + * `REACT_BLOCKS` entry for the `ObjectChart` react block carries + * `schemaType: 'object-chart'` and `schema: ChartConfigSchema`, and lists + * `xAxis` / `yAxis` among its `dataProps`; `ChartConfigSchema.xAxis` is ONE + * `ChartAxisSchema` and `ChartConfigSchema.yAxis` an ARRAY of them. Blocks (a) + * and (f) read that off the installed spec rather than writing it down here. + * + * ## `xAxis` is the object ONLY (seat decision on objectui#10518, option A) + * + * No string arm and no fold onto `xAxisKey`: objectui#7113's bare-string alias + * is `ChartSchema`'s alone, and the spec's `ChartConfigSchema.xAxis` has no + * string arm. A bare column name and a list are refused with the `{ field }` + * remedy. The renderer's own tolerance of a bare string is untouched and stays + * pinned where it lives (`ObjectChart.absentCategoryAxisRefusal-8168.test.tsx`). * * ## The defect (measured on the branch point before the change) * - * The key was declared on neither face, so it rode `BaseSchema`'s + * Neither key was declared on either face, so both rode `BaseSchema`'s * `.passthrough()` — KEPT, read by the renderer, and UNCHECKED: - * `yAxis: [{ field: 'n', stepSize: 'big' }]`, an entry carrying `logScale`, - * and a single axis object all parsed green. The shape pin in (a) and every - * refusal in (c) and (d) are red on that tree; the PR records the run. + * `yAxis: [{ field: 'n', stepSize: 'big' }]`, `xAxis: { field: 'n', min: 'zero' }`, + * keys the spec only names as aliases, a single `yAxis` object and a bare + * `xAxis` string all parsed green. The shape pins in (a) and (f) and every + * refusal in (c), (d), (h) and (i) are red on that tree; the PR records the run. * - * ## The lit control is the real producer + * ## The lit controls are the real producers * - * The objectstack showcase command-center page builds its dataset-bound charts - * through one helper that writes `yAxis: [{ field: values[0], stepSize: 1 }]` - * inside the node's `properties` bag. `SchemaRenderer` hoists that bag onto the - * node, so the node below is the shape the mirror judges — flattened, with the - * helper's own keys. It parses on the branch point and on this change alike: - * the declaration narrows nothing that producer writes. + * `yAxis`: the objectstack showcase command-center page builds its + * dataset-bound charts through one helper that writes + * `yAxis: [{ field: values[0], stepSize: 1 }]` inside the node's `properties` + * bag. `SchemaRenderer` hoists that bag onto the node, so the node below is the + * shape the mirror judges — flattened, with the helper's own keys. + * `xAxis`: the showcase `renewals-pipeline` page writes `xAxis={{ field }}` and + * `yAxis={[{ field, format }]}` on the `ObjectChart` react block. Both parse on + * the branch point and on this change alike: the declarations narrow nothing + * those producers write. * * ## How refusals are asserted * * By issue `code` and `path`. Where a message is asserted, it is compared * against the SPEC's own message for the same axis, read off the spec schema in - * the same test — never against a string written here. + * the same test — never against a string written here. The one exception is + * the `xAxis` remedy, whose wording (`xAxis: { field`) IS the contract the seat + * decision asked for. */ import { describe, it, expect } from 'vitest'; @@ -123,6 +139,8 @@ type Expect = T; * precisely the before-state. */ export type assertionYAxisIsTheSpecList = Expect>; +/** `xAxis` is the spec's ONE object — not a string, not a list, not `any`. */ +export type assertionXAxisIsTheSpecObject = Expect>; /** The helper can FAIL — synthetic control (an undeclared key reads `any`). */ export type assertionEqualCanFail = Expect, false>>; @@ -265,3 +283,149 @@ describe('objectui#10518 (e) — the TS twin types `yAxis` with the spec\'s `Cha expect(refused).toHaveLength(4); }); }); + +/* ══ `xAxis` — the spec's ONE axis object, and only that ══════════════════ */ + +/** The showcase `renewals-pipeline` producer's axis props and binding, on this node. */ +const RENEWALS_AXES = { + objectName: 'showcase_invoice', + aggregate: { field: 'total', function: 'sum', groupBy: 'status' }, + xAxis: { field: 'status' }, + yAxis: [{ field: 'total', format: '$0,0' }], +} as const; + +/** The remedy's own spelling — the contract the seat decision named. */ +const X_AXIS_REMEDY = 'xAxis: { field'; + +/* ── (f) the declaration IS the spec's ───────────────────────────────────── */ + +describe('objectui#10518 (f) — `xAxis` is declared, with the spec axis object', () => { + it('the spec declares `xAxis` on the `object-chart` react block, as `ChartConfigSchema`\'s ONE object', () => { + const block = REACT_BLOCKS.find((b) => b.schemaType === 'object-chart'); + expect(block!.dataProps).toContain('xAxis'); + // The object arm, read off the spec: an object parses, a bare string and a + // list are refused AT `xAxis` — the spec's config has no string arm. + expect(SpecChartConfigSchema.safeParse({ type: 'bar', xAxis: { field: 'n' } }).success).toBe(true); + for (const xAxis of ['n', [{ field: 'n' }]]) { + const r = SpecChartConfigSchema.safeParse({ type: 'bar', xAxis }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path)).toEqual([['xAxis']]); + } + }); + + it('`xAxis` is in the mirror\'s own shape — declared, not passed through', () => { + expect(Object.keys(mirrorShape)).toContain('xAxis'); + }); + + it('every spec axis key is accepted on `xAxis`, and the value survives unchanged', () => { + const axis = { ...FULL_AXIS, field: 'status', position: 'bottom' }; + expect(parsed(chart({ xAxis: axis })).xAxis).toEqual(axis); + }); + + it('omitted keys stay omitted — the spec defaults are not written into the output', () => { + expect(parsed(chart({ xAxis: { field: 'status' } })).xAxis).toEqual({ field: 'status' }); + }); + + it('NOT folded — the object survives the parse, and no `xAxisKey` is minted from it', () => { + const out = parsed(chart({ xAxis: { field: 'status', title: 'Status' } })); + expect(out.xAxis).toEqual({ field: 'status', title: 'Status' }); + expect(out).not.toHaveProperty('xAxisKey'); + }); +}); + +/* ── (g) the lit control: the real producer ──────────────────────────────── */ + +describe('objectui#10518 (g) — LIT CONTROL: the showcase `renewals-pipeline` axes parse, unchanged', () => { + it('on the mirror, with `xAxis` / `yAxis` exactly as written', () => { + const out = parsed(chart(RENEWALS_AXES)); + expect(out.xAxis).toEqual({ field: 'status' }); + expect(out.yAxis).toEqual([{ field: 'total', format: '$0,0' }]); + }); + + it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => { + expect(safeValidateSchema(chart(RENEWALS_AXES)).success).toBe(true); + }); +}); + +/* ── (h) values and keys inside the object are CHECKED now ───────────────── */ + +describe('objectui#10518 (h) — a malformed `xAxis` object is refused at `xAxis`, carrying the spec\'s own diagnostic', () => { + it.each([ + ['a non-number `min`', { field: 'status', min: 'zero' }], + ['a `position` outside the four', { field: 'status', position: 'middle' }], + ['an undeclared key the spec names an alias of (`grid`)', { field: 'status', grid: true }], + ['a missing `field` — the spec requires it', { title: 'Status' }], + ])('%s', (_label, axis) => { + const [issue] = issues(chart({ xAxis: axis })); + expect(issue!.code).toBe('invalid_union'); + expect(issue!.path).toEqual(['xAxis']); + expect(issue!.message).toContain(specMessage(axis)); + }); + + it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => { + expect(safeValidateSchema(chart({ xAxis: { field: 'status', min: 'zero' } })).success).toBe(false); + }); + + /* CONTROL for every refusal above: the same objects, minus the defect, parse. */ + it('CONTROL — the same objects, minus the defect, are accepted', () => { + expect(() => parsed(chart({ xAxis: { field: 'status', min: 0, position: 'bottom', showGridLines: true } }))).not.toThrow(); + expect(() => parsed(chart({ xAxis: { field: 'status', title: 'Status' } }))).not.toThrow(); + }); +}); + +/* ── (i) a bare string and a list are refused, with the remedy ───────────── */ + +describe('objectui#10518 (i) — `xAxis` is the spec object, and only that', () => { + it.each([ + ['a bare column name', 'status'], + ['a list of axis objects', [{ field: 'status' }]], + ['a number', 42], + ])('%s is refused at `xAxis`, naming the `{ field }` remedy', (_label, xAxis) => { + const [issue] = issues(chart({ xAxis })); + expect(issue!.code).toBe('invalid_union'); + expect(issue!.path).toEqual(['xAxis']); + expect(issue!.message).toContain(X_AXIS_REMEDY); + }); + + it('the bare string is refused at the public door too', () => { + expect(safeValidateSchema(chart({ xAxis: 'status' })).success).toBe(false); + }); + + it('CONTROL — the same column as the spec object parses, and a malformed OBJECT is not told the remedy', () => { + expect(parsed(chart({ xAxis: { field: 'status' } })).xAxis).toEqual({ field: 'status' }); + const [issue] = issues(chart({ xAxis: { field: 'status', min: 'zero' } })); + expect(issue!.message).not.toContain(X_AXIS_REMEDY); + }); +}); + +/* ── (j) the TS twin carries the same contract ───────────────────────────── */ + +describe('objectui#10518 (j) — the TS twin types `xAxis` with the spec\'s ONE `ChartAxis`', () => { + it('accepts the spec shape at the authoring site', () => { + const node: ObjectChartSchema = { + type: 'object-chart', + chartType: 'bar', + objectName: 'showcase_invoice', + aggregate: { field: 'total', function: 'sum', groupBy: 'status' }, + xAxis: { field: 'status', title: { en: 'Status', 'zh-CN': '状态' } }, + yAxis: [{ field: 'total', format: '$0,0' }], + }; + expect(node.xAxis?.field).toBe('status'); + }); + + it('refuses what the mirror refuses — checked by `tsc -p tsconfig.test.json`', () => { + // Not `as const`, for the reason block (e) gives. + const base = { type: 'object-chart' as const, chartType: 'bar' as const }; + const refused: ObjectChartSchema[] = [ + // @ts-expect-error — `xAxis` is the spec's axis OBJECT, not a bare column name + { ...base, xAxis: 'status' }, + // @ts-expect-error — `xAxis` is ONE axis object, not a list + { ...base, xAxis: [{ field: 'status' }] }, + // @ts-expect-error — `field` is required on a spec axis object + { ...base, xAxis: { title: 'Status' } }, + // @ts-expect-error — `min` is a number + { ...base, xAxis: { field: 'status', min: 'zero' } }, + ]; + expect(refused).toHaveLength(4); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index ea4180492a..3bae0a91fa 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4148,9 +4148,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'data-display.zod.ts#ChartSchema', 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', - // ⭐ ONE entry, FIVE spec crossings — three cards put them there and every - // ground is recorded, because any one alone is enough to keep this membership - // and deleting the entry needs all of them to be gone. + // ⭐ ONE entry, SIX spec crossings over FIVE spec symbols — three cards put + // them there and every ground is recorded, because any one alone is enough to + // keep this membership and deleting the entry needs all of them to be gone. // - objectui#7946 (rework round): `aggregate` is `SpecChartAggregateSchema` by // reference rather than the local near-copy the first cut declared, so a // spec bump that widens or narrows the object-bound aggregation vocabulary @@ -4162,11 +4162,11 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // own declaration: `DashboardRenderer` forwards `widget.compareTo` // verbatim). So a spec bump that moves the chart drill vocabulary, the // i18n label union, or the widget's comparison directive moves ONE side too. - // - objectui#10518: `yAxis` is the spec's `ChartAxisSchema` by reference — - // the axis config LIST `ChartConfigSchema.yAxis` declares, and the spec's - // `` react block (`schemaType: 'object-chart'`) publishes — so - // a spec bump that moves the axis vocabulary moves ONE side of this pair, - // as it does for `data-display.zod.ts#ChartSchema` above. + // - objectui#10518: `xAxis` / `yAxis` are the spec's `ChartAxisSchema` by + // reference — one object and a LIST, as `ChartConfigSchema` declares them + // and the spec's `ObjectChart` react block (`schemaType: 'object-chart'`) + // publishes them — so a spec bump that moves the axis vocabulary moves ONE + // side of this pair, as it does for `data-display.zod.ts#ChartSchema` above. // Either way it is exactly what this list exists to make legible rather than // mysterious. // objectui#8651: the `calendar` CONTAINER is the spec's own diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index a54ad6d1ce..51ad74bf43 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -139,8 +139,9 @@ import type { ChartDrillDown, I18nLabel, DashboardWidget as SpecDashboardWidget, - // objectui#10518 — the spec's axis config object, the element type of - // `ObjectChartSchema.yAxis`. Aliased for the reason `SpecGanttConfig` above is. + // objectui#10518 — the spec's axis config object: the type of + // `ObjectChartSchema.xAxis` and the element type of `ObjectChartSchema.yAxis`. + // Aliased for the reason `SpecGanttConfig` above is. ChartAxis as SpecChartAxis, } from '@objectstack/spec/ui'; @@ -4251,11 +4252,11 @@ export type KanbanConditionalFormattingRule = * * ## A third card, and a channel neither census sees (objectui#10518) * - * `yAxis` (see the member) is read by NO `schema.KEY` expression in - * `ObjectChart.tsx`: the component spreads the node into the schema it hands - * `ChartRenderer`, and `normalizeChartSchema` reads it there. Both census pins - * above read `schema.KEY` off that one file, so neither lists it — as a read or - * as an exception — and neither was moved. Its pin is + * `xAxis` and `yAxis` (see the members) are read by NO `schema.KEY` + * expression in `ObjectChart.tsx`: the component spreads the node into the + * schema it hands `ChartRenderer`, and `normalizeChartSchema` reads them there. + * Both census pins above read `schema.KEY` off that one file, so neither lists + * them — as reads or as exceptions — and neither was moved. Their pin is * `__tests__/object-chart-axis-config-10518.test.ts`; the render path was * measured once with a real registry probe, recorded on the card's PR. */ @@ -4373,8 +4374,8 @@ export interface ObjectChartSchema extends BaseSchema { aggregate?: ChartAggregate; /** * INTERNAL (relay-composed) — the category column the renderer binds the x - * axis to. Authors write `xAxisField` above (or, one layer down, the spec's - * `xAxis: { field }`, which `normalizeChartSchema` resolves); the five + * axis to. Authors write `xAxisField` above (or the spec's {@link xAxis} + * object below, whose `field` `normalizeChartSchema` resolves); the five * producers of an `object-chart` node compute this key. * * Typed `string` from `ChartRendererProps.schema.xAxisKey`, the read this @@ -4502,6 +4503,27 @@ export interface ObjectChartSchema extends BaseSchema { * value check that `.passthrough()` was skipping. */ compareTo?: SpecDashboardWidget['compareTo']; + /** + * AUTHORABLE — the category (x) axis: ONE `@objectstack/spec` axis CONFIG + * object, the type of `ChartConfigSchema.xAxis`. Its `field` names the + * category column when neither `aggregate.groupBy` nor {@link xAxisKey} does + * (`resolveChartCategoryField`'s order); its other keys are presentation. + * + * Declared by objectui#10518 beside {@link yAxis}, for the same three reasons + * (see that member): the spec's `ObjectChart` react block lists `xAxis` in its + * `dataProps` with `ChartConfigSchema` as its schema; `normalizeChartSchema` + * reads every spec axis key off the object; and a real producer writes it — + * the objectstack showcase `renewals-pipeline` page's `xAxis: { field }`. + * + * ⛔ The object ONLY. There is no `string` arm and no fold onto + * {@link xAxisKey} (seat decision on objectui#10518, option A): the bare + * string is objectui#7113's alias on `ChartSchema` alone, the spec's + * `ChartConfigSchema.xAxis` has no string arm, and no producer on this node + * writes one. The renderer still TOLERATES a bare string — that is + * `normalizeChartSchema`'s, untouched — but it does not compile here and the + * mirror refuses it, as it refuses a list, with the `{ field }` remedy. + */ + xAxis?: SpecChartAxis; /** * AUTHORABLE — the value (y) axes: an ARRAY of `@objectstack/spec`'s axis * CONFIG objects, the type of `ChartConfigSchema.yAxis`. The first entry is diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index ce6da55f42..3dcbc78f30 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2406,6 +2406,53 @@ const OBJECT_CHART_Y_AXIS_IS_A_LIST_GUIDANCE = + '`yAxis: [{ field: \'task_count\', stepSize: 1 }]`, one entry per value axis (a second entry declares ' + 'the right-hand axis). A single axis object or a bare column name is not a member of the protocol.'; +/** + * The remedy for an `ObjectChartSchema.xAxis` that is not an object + * (objectui#10518) — the spec's own spelling, the `{ field }` form the + * showcase `renewals-pipeline` page writes on this node. + */ +const OBJECT_CHART_X_AXIS_IS_AN_OBJECT_GUIDANCE = + '`xAxis` on an `object-chart` is `@objectstack/spec`\'s axis OBJECT — write `xAxis: { field: \'status\' }` ' + + '(plus `title`, `format`, … as the spec declares them). A bare column name or a list is not a member of ' + + 'the protocol on this node: the bare-string spelling is an alias of `xAxisKey` on the `chart` node only.'; + +/** + * The message for a refused `ObjectChartSchema.xAxis` (objectui#10518). + * + * `xAxis` here is ONLY the spec's axis object: no string arm and no fold. The + * objectui#7113 bare-string alias of `xAxisKey` is a ruling scoped to + * `ChartSchema` (seat decision on objectui#10518, option A), and the spec's + * `ChartConfigSchema.xAxis` — the `ObjectChart` react block's schema — has no + * string arm either. + * + * Why the member below is a two-arm union whose second arm is `z.never()`: the + * spec's object answers a string with a bare "expected object", and zod runs a + * ONE-option union as its option, so a single-arm union's error hook never + * fires. `z.never()` admits nothing and adds nothing to the inferred type; what + * it buys is that every refusal becomes one `invalid_union` at `xAxis`, worded + * here — the shape `ChartSchema.xAxis` already reports (`chartXAxisUnionError` + * in `data-display.zod.ts`): + * + * - an OBJECT can only have meant the axis object, and that arm's issues are + * surfaced with their paths (`xAxis.min: …`), in the spec's own words — the + * alias hint included; + * - anything else — a bare column name, a list, a number — gets the remedy. + * + * It is a MESSAGE, not an accept: the issue stays `invalid_union` at `xAxis`, + * and nothing that was refused is admitted. + */ +function objectChartXAxisError(issue: z.core.$ZodRawIssue): string | undefined { + if (issue.code !== 'invalid_union') return undefined; + const input = issue.input; + if (!input || typeof input !== 'object' || Array.isArray(input)) return OBJECT_CHART_X_AXIS_IS_AN_OBJECT_GUIDANCE; + // Arm order is the union's: [axis object, never]. + const axisArm = issue.errors[0] ?? []; + const detail = axisArm + .map((i) => `${['xAxis', ...i.path.map(String)].join('.')}: ${i.message}`) + .join('; '); + return `\`xAxis\` is not a valid \`@objectstack/spec\` axis object — ${detail || 'Invalid input'}`; +} + /** * ObjectChart Schema */ @@ -2509,34 +2556,47 @@ export const ObjectChartSchema = BaseSchema.extend({ // the position it reads (`stripImportedDefaults()`). compareTo: stripImportedDefaults(SpecDashboardWidgetSchema).shape.compareTo .describe('Period-over-period comparison directive, forwarded verbatim from the dashboard widget key of the same name — bound BY REFERENCE to `DashboardWidgetSchema.shape.compareTo` so the producer and this consumer cannot drift into two dialects.'), - // ── objectui#10518: the value axes, as `@objectstack/spec`'s axis config - // LIST — the `object-chart` sibling of objectui#7690, under the same ruling - // (5809510046, branch 2 — declare), which triage reused for this node. + // ── objectui#10518: the two axes, as `@objectstack/spec`'s axis config — + // ONE object for `xAxis`, a LIST for `yAxis` — the `object-chart` sibling of + // objectui#7690, under the same ruling (5809510046, branch 2 — declare), + // which triage reused for this node. // // Three readings, each taken rather than assumed (the TS twin in // `../objectql.ts` carries them in full): - // - the spec DECLARES the key on this node: its `REACT_BLOCKS` entry for - // `` has `schemaType: 'object-chart'` and - // `schema: ChartConfigSchema`, and lists `yAxis` among its `dataProps`; - // `ChartConfigSchema.yAxis` is an ARRAY of `ChartAxisSchema`; - // - the node RENDERS it: `ObjectChart` spreads the node into the schema it - // hands `ChartRenderer`, whose `normalizeChartSchema` reads the spec's + // - the spec DECLARES both keys on this node: its `REACT_BLOCKS` entry for + // the `ObjectChart` react block has `schemaType: 'object-chart'` and + // `schema: ChartConfigSchema`, and lists `xAxis` / `yAxis` among its + // `dataProps`; `ChartConfigSchema.xAxis` is ONE `ChartAxisSchema` and + // `ChartConfigSchema.yAxis` an ARRAY of them; + // - the node RENDERS them: `ObjectChart` spreads the node into the schema + // it hands `ChartRenderer`, whose `normalizeChartSchema` reads the spec's // axis keys (tied to them from the renderer's side by // `normalizeChartSchema.specAxisKeys-7690.test.ts`); - // - a real producer WRITES it: the objectstack showcase command-center + // - real producers WRITE them: the objectstack showcase command-center // page's dataset-bound `object-chart` authors `yAxis: [{ field, stepSize: 1 }]` - // to pin integer ticks on a count axis. + // to pin integer ticks on a count axis, and its `renewals-pipeline` page + // writes `xAxis: { field }` on the `ObjectChart` react block. // - // Until here the key rode `BaseSchema`'s `.passthrough()` — kept, read and - // UNCHECKED — so `yAxis: [{ field: 'n', stepSize: 'big' }]` parsed green. + // Until here both keys rode `BaseSchema`'s `.passthrough()` — kept, read and + // UNCHECKED — so `yAxis: [{ field: 'n', stepSize: 'big' }]` and + // `xAxis: { field: 'n', min: 'zero' }` parsed green. // // BY REFERENCE, not restated: the key set, the value domains and the strict // refusal are the spec's. `stripImportedDefaults` keeps the spec's two - // `.default()`s out of the parse output, exactly as on `ChartSchema.yAxis`. - // Only the spec's LIST is a member: the single object and the bare column - // name `normalizeChartSchema` tolerates are refused (the liveness read on - // objectui#10518, a one-time reading, found no producer on this node writing - // either). + // `.default()`s out of the parse output, exactly as on `ChartSchema`. + // Only the spec's shapes are members: a bare column name or a list on + // `xAxis`, and a single object or a bare column name on `yAxis` — the + // tolerances `normalizeChartSchema` honours — are refused, each with its + // remedy (the liveness read on objectui#10518, a one-time reading, found no + // producer on this node writing any of them). ⛔ No string arm and no fold on + // `xAxis`: see `objectChartXAxisError` above. + xAxis: z + .union([stripImportedDefaults(SpecChartAxisSchema), z.never()], { error: objectChartXAxisError }) + .optional() + .describe( + 'AUTHORABLE — category (x) axis: ONE @objectstack/spec ChartAxis object, by reference (field required, strict). ' + + 'Its field names the category column when neither aggregate.groupBy nor xAxisKey does. A bare column name or a list is refused.', + ), yAxis: z .array(stripImportedDefaults(SpecChartAxisSchema), { error: (issue) => (issue.code === 'invalid_type' ? OBJECT_CHART_Y_AXIS_IS_A_LIST_GUIDANCE : undefined), From 0e710207d9a514838dd2ae41e7e573f7e99ca9a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:36:56 +0000 Subject: [PATCH 3/5] docs(changeset): name what each now-refused ObjectChartSchema axis shape draws today (objectui#10518) The lead-in said every now-refused document draws a chart today. A per-case render measurement shows that is false for three shapes: an xAxis object with no field, a list or other non-object xAxis, and a yAxis entry with no field on a chart whose series come from yAxis alone. Each can draw a refusal instead. The lead-in now says most of them draw a chart, and the affected bullets name the exception and what it draws. The zod comment that called a list xAxis a normalizer tolerance is corrected the same way. Prose only: the frontmatter is byte-identical. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../10518-object-chart-y-axis-declared.md | 18 ++++++++++++++---- packages/types/src/zod/objectql.zod.ts | 14 ++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.changeset/10518-object-chart-y-axis-declared.md b/.changeset/10518-object-chart-y-axis-declared.md index 57764a4c6c..d6181b564c 100644 --- a/.changeset/10518-object-chart-y-axis-declared.md +++ b/.changeset/10518-object-chart-y-axis-declared.md @@ -21,18 +21,28 @@ the `object-chart` sibling of the objectui#7690 declaration on `ChartSchema`. has no string arm (seat decision on objectui#10518, option A). ⚠️ Shipped as `minor`, not `patch`: documents that validated before now **refuse** (the accept set -narrows), and each of them draws a chart today: +narrows), and most of them draw a chart today. The exceptions are named in their bullets: - An `xAxis` object with a malformed value (`min: 'zero'`, `position: 'middle'`), an undeclared key (`grid`, `logScale`) or no `field`. It is refused as one issue at `xAxis` whose message carries - the spec's own diagnostic, including its alias hint. + the spec's own diagnostic, including its alias hint. With a malformed value or an undeclared key + the chart draws today, because the object's `field` still names the category column. An object + with no `field` names no category, so the chart draws only when another source names one: an + `xAxisKey`, or the dimension of a dataset-bound chart. Otherwise it draws a refusal instead: the + objectui#8168 missing-category-axis screen on an object-bound chart that also declares no + `aggregate.groupBy`, and the renderer's `missing-category-key` error on any other chart. - An `xAxis` written as a bare column name (`xAxis: 'status'`), a list of axis objects, or any other non-object value. It is refused with the remedy `xAxis: { field }`. The renderer's normalizer still honours a bare string as a tolerance, and that is unchanged, but no producer on this node writes - one. + one. A bare string draws a chart today. A list or another non-object names no category either, + so it draws a chart only under the same condition as an `xAxis` object with no `field`, and + otherwise the same refusal. - A `yAxis` entry with a malformed value (`stepSize: 'big'`, `position: 'middle'`), an undeclared key (`logScale`, `grid`) or no `field`. It is refused at the entry's own path. The renderer ignores - what it cannot read and draws the rest. + what it cannot read and draws the rest. The exception is a chart whose series come from `yAxis` + alone (no `series`, no `categories`, not dataset-bound): there an entry with no `field` plots + nothing, and when no entry names a `field` the chart draws the renderer's `no-plottable-series` + refusal instead. - A `yAxis` written as a single object or a bare column name. The renderer's normalizer honours both as a tolerance. Neither is a member of the spec's list, and no producer on this node writes either, so they are refused with a remedy (the reading objectui#7690 applied to diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 3dcbc78f30..9d5386b47d 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2584,12 +2584,14 @@ export const ObjectChartSchema = BaseSchema.extend({ // BY REFERENCE, not restated: the key set, the value domains and the strict // refusal are the spec's. `stripImportedDefaults` keeps the spec's two // `.default()`s out of the parse output, exactly as on `ChartSchema`. - // Only the spec's shapes are members: a bare column name or a list on - // `xAxis`, and a single object or a bare column name on `yAxis` — the - // tolerances `normalizeChartSchema` honours — are refused, each with its - // remedy (the liveness read on objectui#10518, a one-time reading, found no - // producer on this node writing any of them). ⛔ No string arm and no fold on - // `xAxis`: see `objectChartXAxisError` above. + // Only the spec's shapes are members: a bare column name, a list or any + // other non-object on `xAxis`, and a single object or a bare column name on + // `yAxis`, are refused, each with its remedy (the liveness read on + // objectui#10518, a one-time reading, found no producer on this node writing + // any of them). Of those, `normalizeChartSchema` honours the bare string on + // `xAxis` and both `yAxis` shapes as tolerances; a list on `xAxis` names no + // category there. ⛔ No string arm and no fold on `xAxis`: see + // `objectChartXAxisError` above. xAxis: z .union([stripImportedDefaults(SpecChartAxisSchema), z.never()], { error: objectChartXAxisError }) .optional() From 5367bc102087a734ecdbb8fe0044bf487ddf8834 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:12:53 +0000 Subject: [PATCH 4/5] docs(changeset): say only what holds for every chart type about the now-refused axis shapes (objectui#10518) Review round 2 showed that the round-3 conditions were too broad. Whether a no-category or no-field shape draws depends on the chart type and on the rows: a `name` column, or the `value` fallback. The lead-in now says that several of the now-refused documents draw a chart today, and that what the rest draw depends on the chart type, the rows and the node's other sources. The bullets state only equivalences the renderer guarantees: a dropped key changes nothing; a bare string reads as `{ field }`; a list, a non-object or a missing field names no category; a no-field entry adds no series. Prose only; the frontmatter is byte-identical. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../10518-object-chart-y-axis-declared.md | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/.changeset/10518-object-chart-y-axis-declared.md b/.changeset/10518-object-chart-y-axis-declared.md index d6181b564c..bb5f0e4046 100644 --- a/.changeset/10518-object-chart-y-axis-declared.md +++ b/.changeset/10518-object-chart-y-axis-declared.md @@ -21,32 +21,28 @@ the `object-chart` sibling of the objectui#7690 declaration on `ChartSchema`. has no string arm (seat decision on objectui#10518, option A). ⚠️ Shipped as `minor`, not `patch`: documents that validated before now **refuse** (the accept set -narrows), and most of them draw a chart today. The exceptions are named in their bullets: +narrows). Several of them draw a chart today, which is why this ships as `minor`; what the rest draw +depends on the chart type, on the rows, and on the node's other category and series sources: - An `xAxis` object with a malformed value (`min: 'zero'`, `position: 'middle'`), an undeclared key (`grid`, `logScale`) or no `field`. It is refused as one issue at `xAxis` whose message carries - the spec's own diagnostic, including its alias hint. With a malformed value or an undeclared key - the chart draws today, because the object's `field` still names the category column. An object - with no `field` names no category, so the chart draws only when another source names one: an - `xAxisKey`, or the dimension of a dataset-bound chart. Otherwise it draws a refusal instead: the - objectui#8168 missing-category-axis screen on an object-bound chart that also declares no - `aggregate.groupBy`, and the renderer's `missing-category-key` error on any other chart. + the spec's own diagnostic, including its alias hint. The renderer drops a malformed `min` or + `position` and never reads `grid` or `logScale`, so an object carrying them draws exactly what it + draws without them. An object with no `field` names no category of its own. - An `xAxis` written as a bare column name (`xAxis: 'status'`), a list of axis objects, or any other non-object value. It is refused with the remedy `xAxis: { field }`. The renderer's normalizer still honours a bare string as a tolerance, and that is unchanged, but no producer on this node writes - one. A bare string draws a chart today. A list or another non-object names no category either, - so it draws a chart only under the same condition as an `xAxis` object with no `field`, and - otherwise the same refusal. + one. A bare string names the category column exactly as `{ field }` does, so it draws what that + object draws. A list or any other non-object names no category, like an object with no `field`. - A `yAxis` entry with a malformed value (`stepSize: 'big'`, `position: 'middle'`), an undeclared key (`logScale`, `grid`) or no `field`. It is refused at the entry's own path. The renderer ignores - what it cannot read and draws the rest. The exception is a chart whose series come from `yAxis` - alone (no `series`, no `categories`, not dataset-bound): there an entry with no `field` plots - nothing, and when no entry names a `field` the chart draws the renderer's `no-plottable-series` - refusal instead. + what it cannot read and uses the rest of the entry. An entry with no `field` names no column, so it + adds no series. - A `yAxis` written as a single object or a bare column name. The renderer's normalizer honours both as a tolerance. Neither is a member of the spec's list, and no producer on this node writes either, so they are refused with a remedy (the reading objectui#7690 applied to - `ChartSchema.yAxis`). The normalizer is unchanged. + `ChartSchema.yAxis`). The normalizer is unchanged. It reads a single object as a one-entry list, so + a single object with no `field` adds no series either. A TS consumer that typed a bare-string `xAxis` onto an `ObjectChartSchema` value no longer compiles. The one in-repo instance, a renderer-tolerance test fixture, now carries an explicit cast. From b2936fc6693bad6bed722e0f0f47e8fe2b3eb6b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:25:19 +0000 Subject: [PATCH 5/5] docs(changeset): attribute the minor bump to the accept-set narrowing (objectui#10518) The lead-in credited the `minor` level to the refused documents that still draw a chart. The version policy's reason is the breaking semantics, which here is the narrowed accept set itself. The lead-in paragraph is replaced with the seat's exact text and rewrapped to the file's width. The bullets and the frontmatter are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10518-object-chart-y-axis-declared.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/10518-object-chart-y-axis-declared.md b/.changeset/10518-object-chart-y-axis-declared.md index bb5f0e4046..f612d96bd8 100644 --- a/.changeset/10518-object-chart-y-axis-declared.md +++ b/.changeset/10518-object-chart-y-axis-declared.md @@ -21,8 +21,9 @@ the `object-chart` sibling of the objectui#7690 declaration on `ChartSchema`. has no string arm (seat decision on objectui#10518, option A). ⚠️ Shipped as `minor`, not `patch`: documents that validated before now **refuse** (the accept set -narrows). Several of them draw a chart today, which is why this ships as `minor`; what the rest draw -depends on the chart type, on the rows, and on the node's other category and series sources: +narrows), and a narrowed accept set is breaking semantics, which this repo's version policy ships as +`minor`. Several of the refused documents draw a chart today; what the rest draw depends on the +chart type, on the rows, and on the node's other category and series sources: - An `xAxis` object with a malformed value (`min: 'zero'`, `position: 'middle'`), an undeclared key (`grid`, `logScale`) or no `field`. It is refused as one issue at `xAxis` whose message carries