From 2b53b62084c44375bbba79a2f8553c55308918d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:55:20 +0000 Subject: [PATCH 1/5] feat(types)!: object-kanban's conditionalFormatting takes the spec list view's { condition, style } rule only; the native and flat-colour rule dialects are refused by name (objectui#11522) KanbanConditionalFormattingRuleSchema is the spec ListViewSchema rule element by reference (.extend()), with the list view's own condition arm and the six retired keys (field, operator, value, backgroundColor, borderColor, textColor) as retirement tombstones. The TS twin extends SpecConditionalFormattingRule with the same keys as `?: never`; KanbanNativeConditionalFormattingRule is removed. The types pin is turned around to pin both refusals on all three zod faces. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../kanban-conditional-formatting.test.ts | 231 ++++++++++++++---- .../spec-expression-wire-slots-10946.test.ts | 12 +- .../src/__tests__/zod-mirror-parity.test.ts | 5 +- packages/types/src/index.ts | 1 - packages/types/src/objectql.ts | 78 ++++-- packages/types/src/zod/objectql.zod.ts | 93 +++++-- 6 files changed, 312 insertions(+), 108 deletions(-) diff --git a/packages/types/src/__tests__/kanban-conditional-formatting.test.ts b/packages/types/src/__tests__/kanban-conditional-formatting.test.ts index 10a2229684..7ed0b1d508 100644 --- a/packages/types/src/__tests__/kanban-conditional-formatting.test.ts +++ b/packages/types/src/__tests__/kanban-conditional-formatting.test.ts @@ -7,67 +7,192 @@ */ /** - * Kanban conditional formatting accepts CEL (#1584). + * `object-kanban`'s `conditionalFormatting` speaks ONE rule dialect — the spec + * list view's `{ condition, style }` — and refuses the two it used to take BY + * NAME (objectui#11522; triage ruling 5963861071: retire, not widen, no alias + * window). * - * Since kanban card styling moved onto the shared CEL evaluator, the kanban - * schema's type + zod contract must match the runtime: a rule may be the native - * `{ field, operator, value }` shape OR the spec `{ condition, style }` CEL - * shape. This locks both so the two can't drift back apart. + * TURNED AROUND, not deleted. This file used to pin that a kanban rule accepted + * BOTH the native `{ field, operator, value }` comparison and the spec shape + * (#1584). It now pins the opposite, for the two retired dialects: + * + * - the native rule `{ field, operator, value, backgroundColor, borderColor }`, + * - the flat CEL rule — a `condition` with a colour at the TOP LEVEL + * (`{ condition, backgroundColor }`) instead of inside `style`. + * + * Each refusal is read on all three zod faces an author meets — the + * `ObjectKanbanSchema` mirror, the tolerant document face + * (`safeValidateSchema`) and the strict authoring face + * (`StrictAnyComponentSchema`) — at the retired key's OWN path, and the message + * there names the retirement and the respelling. A bare `success: false` would + * not be a refusal pin: a document with an unrelated mistake fails too. The + * accepted `{ condition, style }` rule in the same document is the live control + * on every face. + * + * The TS face is pinned by `tsc -p tsconfig.test.json` (this package's + * `type-check`): the `@ts-expect-error` lines below fail the build the moment a + * retired key type-checks again. */ -import { describe, it, expect } from 'vitest'; -import { ObjectKanbanSchema } from '../zod/index.zod'; -import type { KanbanConditionalFormattingRule } from '../objectql'; - -describe('kanban conditionalFormatting — zod contract', () => { - // `groupBy`, not `groupField`: the lane key the renderer reads, declared on - // both faces by objectui#7322 (which retired `groupField` on this node). - const base = { type: 'object-kanban', objectName: 'task', groupBy: 'status' }; - - it('accepts the native { field, operator, value } rule (back-compat)', () => { - const parsed = ObjectKanbanSchema.safeParse({ - ...base, - conditionalFormatting: [ - { field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }, - ], - }); - expect(parsed.success).toBe(true); +import { describe, it, expect, expectTypeOf } from 'vitest'; +import type { z } from 'zod'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { ObjectKanbanSchema, safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod'; +import { KanbanConditionalFormattingRuleSchema } from '../zod/objectql.zod'; +import type { KanbanConditionalFormattingRule, SpecConditionalFormattingRule } from '../objectql'; + +// `groupBy`, not `groupField`: the lane key the renderer reads, declared on +// both faces by objectui#7322 (which retired `groupField` on this node). +const base = { type: 'object-kanban', objectName: 'task', groupBy: 'status' }; + +/** The accepted spelling — and the live control in every mixed document below. */ +const SPEC_RULE = { condition: "record.status == 'done'", style: { backgroundColor: '#e0ffe0' } }; + +/** The retired native rule, exactly as this file used to accept it. */ +const NATIVE_RULE = { field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }; + +/** The retired flat CEL rule: the colour beside `condition`, not inside `style`. */ +const FLAT_CEL_RULE = { condition: "record.owner == 'bob'", backgroundColor: 'rgb(4, 5, 6)' }; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Face = readonly [label: string, parse: (doc: unknown) => { success: boolean; error?: { issues: Issue[] } }]; + +const FACES: readonly Face[] = [ + ['ObjectKanbanSchema', (doc) => ObjectKanbanSchema.safeParse(doc) as never], + ['safeValidateSchema (tolerant face)', (doc) => safeValidateSchema(doc) as never], + ['StrictAnyComponentSchema (strict face)', (doc) => StrictAnyComponentSchema.safeParse(doc) as never], +]; + +/** The issue each face reports at `conditionalFormatting[index].key`, if any. */ +function issueAt(face: Face, rules: unknown[], index: number, key: string): Issue | undefined { + const parsed = face[1]({ ...base, conditionalFormatting: rules }); + return parsed.error?.issues.find( + (i) => i.path.join('.') === `conditionalFormatting.${index}.${key}`, + ); +} + +/** What every retirement message must carry: the card and the one spelling that replaces the rule. */ +function expectRetirement(issue: Issue | undefined, key: string): void { + expect(issue, `no issue at the retired key \`${key}\``).toBeDefined(); + expect(issue!.message.startsWith(`\`${key}\``), issue!.message).toBe(true); + expect(issue!.message).toContain('RETIRED (objectui#11522)'); + expect(issue!.message).toContain('`{ condition, style }`'); +} + +describe.each(FACES)('objectui#11522 — `object-kanban`.`conditionalFormatting` on %s', (...face) => { + it('CONTROL — the spec `{ condition, style }` rule is accepted', () => { + expect(face[1]({ ...base, conditionalFormatting: [SPEC_RULE] }).success).toBe(true); + }); + + it('refuses the native `{ field, operator, value, backgroundColor }` rule BY NAME, at each retired key', () => { + // Index 1: the accepted rule at index 0 draws no issue in the same parse. + const rules = [SPEC_RULE, NATIVE_RULE]; + expect(face[1]({ ...base, conditionalFormatting: rules }).success).toBe(false); + for (const key of ['field', 'operator', 'value']) { + const issue = issueAt(face, rules, 1, key); + expectRetirement(issue, key); + expect(issue!.message).toContain('native kanban rule dialect'); + } + expectRetirement(issueAt(face, rules, 1, 'backgroundColor'), 'backgroundColor'); + const atControl = face[1]({ ...base, conditionalFormatting: rules }).error!.issues.filter( + (i) => i.path.join('.').startsWith('conditionalFormatting.0'), + ); + expect(atControl).toEqual([]); + }); + + it('refuses the flat CEL rule — a top-level colour beside `condition` — BY NAME, with the `style` respelling', () => { + const rules = [SPEC_RULE, FLAT_CEL_RULE]; + const issue = issueAt(face, rules, 1, 'backgroundColor'); + expectRetirement(issue, 'backgroundColor'); + expect(issue!.message).toContain('`style: { backgroundColor }`'); + // ⛔ Not "the generic union failure": with one dialect left there is no + // union to fail, and no issue sits at the bare rule path. + const parsed = face[1]({ ...base, conditionalFormatting: rules }); + expect(parsed.error!.issues.some((i) => i.code === 'invalid_union' && i.path.join('.') === 'conditionalFormatting.1')).toBe(false); + }); + + it('a top-level colour is refused even beside a `style` map, and so are the other two colour keys', () => { + // The tolerant face used to ACCEPT `{ condition, style, backgroundColor }` + // (the spec arm was a stripping `z.object`) while the shared resolver + // painted the stripped key over `style` anyway. + for (const key of ['backgroundColor', 'borderColor', 'textColor']) { + const rules = [{ ...SPEC_RULE, [key]: 'red' }]; + expectRetirement(issueAt(face, rules, 0, key), key); + } + const textColor = issueAt(face, [{ ...SPEC_RULE, textColor: 'red' }], 0, 'textColor'); + expect(textColor!.message).toContain('`style: { color }`'); + }); +}); + +describe('objectui#11522 — the kanban rule IS the spec list view\'s, by reference', () => { + const specRule = SpecListViewSchema.shape.conditionalFormatting.unwrap().element; + type Shaped = { shape: Record }; + + it('`style` is the spec rule\'s own member (identity, not a copy)', () => { + expect((KanbanConditionalFormattingRuleSchema as unknown as Shaped).shape.style).toBe(specRule.shape.style); }); - it('accepts the spec { condition, style } CEL rule (new)', () => { - const parsed = ObjectKanbanSchema.safeParse({ - ...base, - conditionalFormatting: [ - { condition: "record.status == 'done'", style: { backgroundColor: '#e0ffe0' } }, - ], - }); - expect(parsed.success).toBe(true); + it('CONTROL — the identity check can fail: `condition` is the list view\'s own arm, not the bare spec slot', () => { + expect((KanbanConditionalFormattingRuleSchema as unknown as Shaped).shape.condition).not.toBe(specRule.shape.condition); }); - it('accepts a mix of both shapes in one rule list', () => { - const parsed = ObjectKanbanSchema.safeParse({ - ...base, - conditionalFormatting: [ - { condition: "record.blocked == true", style: { borderColor: 'red' } }, - { field: 'priority', operator: 'in', value: ['high', 'urgent'], backgroundColor: '#fef9c3' }, - ], - }); - expect(parsed.success).toBe(true); + it('inherits the spec rule\'s strictness: an undeclared key is refused with the spec\'s own message', () => { + const parsed = ObjectKanbanSchema.safeParse({ ...base, conditionalFormatting: [{ ...SPEC_RULE, label: 'Done' }] }); + expect(parsed.success).toBe(false); + const issue = parsed.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual(['conditionalFormatting', 0]); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['label']); + // The spec rule alone draws the same refusal — the strictness is the spec's, not a local copy. + expect(specRule.safeParse({ ...SPEC_RULE, label: 'Done' }).success).toBe(false); }); }); -describe('kanban conditionalFormatting — type contract', () => { - it('KanbanConditionalFormattingRule admits both shapes at compile time', () => { - const native: KanbanConditionalFormattingRule = { - field: 'priority', - operator: 'equals', - value: 'high', - backgroundColor: '#fee2e2', - }; - const cel: KanbanConditionalFormattingRule = { - condition: "record.status == 'done'", - style: { backgroundColor: '#e0ffe0' }, - }; - expect(native).toBeTruthy(); - expect(cel).toBeTruthy(); +/* ── the TS face (tsc is the pin) ────────────────────────────────────────── */ + +const accepted: KanbanConditionalFormattingRule = SPEC_RULE; + +const envelope: KanbanConditionalFormattingRule = { + condition: { dialect: 'cel', source: "record.status == 'done'" }, + style: { borderColor: 'red' }, +}; + +const nativeRefused: KanbanConditionalFormattingRule = { + // @ts-expect-error — `field` is `?: never` (objectui#11522): the native rule dialect is retired. + field: 'priority', + // @ts-expect-error — `operator` is `?: never`. + operator: 'equals', + // @ts-expect-error — `value` is `?: never`. + value: 'high', + condition: "record.priority == 'high'", + style: {}, +}; + +const flatColourRefused: KanbanConditionalFormattingRule = { + condition: "record.owner == 'bob'", + style: {}, + // @ts-expect-error — a top-level colour is `?: never` (objectui#11522): it belongs in `style`. + backgroundColor: 'rgb(4, 5, 6)', +}; + +// @ts-expect-error — the flat CEL rule has no `style`, which the rule requires. +const flatCelMissingStyle: KanbanConditionalFormattingRule = { condition: "record.owner == 'bob'" }; + +describe('objectui#11522 — the TS face agrees with the zod face', () => { + it('a `{ condition, style }` rule (string or envelope condition) is the declared rule', () => { + expect(accepted.condition).toBe("record.status == 'done'"); + expect(envelope.style).toEqual({ borderColor: 'red' }); + // The refused literals above are referenced so they are not dead code; tsc is their pin. + expect([nativeRefused, flatColourRefused, flatCelMissingStyle].length).toBe(3); + }); + + it('the two faces declare the same keys, and the rule is the spec-format rule plus the tombstones', () => { + type ZodIn = z.input; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // Every retired key is unwritable on both faces (`?: never` / `z.never().optional()`). + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + // And the accepted rule is exactly the shared spec-format rule. + expectTypeOf().toMatchTypeOf(); }); }); diff --git a/packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts b/packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts index 62fd066950..ff9eaef0d0 100644 --- a/packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts +++ b/packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts @@ -102,7 +102,8 @@ describe('objectui#10946 — the TS faces admit the named view\'s expression slo it('the zod mirror\'s `condition` input is the TS member, by construction', () => { type MirrorRule = Extract['conditionalFormatting']>[number], { style: unknown }>; - type KanbanMirrorRule = Extract, { style: unknown }>; + // One dialect since objectui#11522, so there is no union arm to extract. + type KanbanMirrorRule = z.input; expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); @@ -175,13 +176,16 @@ describe('objectui#10946 — the envelope arm IS the spec\'s slot schema (refere expect(condition.options[1]).toBe(specConditionSlot); }); - it('on the kanban rule union', () => { - const condition = ((KanbanConditionalFormattingRuleSchema as unknown as UnionOf).options[1] as RuleArm).shape.condition; + // objectui#11522 retired the kanban rule's native arm, so the rule is ONE + // object now (the spec list view's own, extended) and there is no union to + // index: `condition` is read straight off its shape. + it('on the kanban rule', () => { + const condition = (KanbanConditionalFormattingRuleSchema as unknown as RuleArm).shape.condition; expect(condition.options[1]).toBe(specConditionSlot); }); it('CONTROL — the reference check can fail: the string arm is NOT the spec slot', () => { - const condition = ((KanbanConditionalFormattingRuleSchema as unknown as UnionOf).options[1] as RuleArm).shape.condition; + const condition = (KanbanConditionalFormattingRuleSchema as unknown as RuleArm).shape.condition; expect(condition.options[0]).not.toBe(specConditionSlot); }); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 6adc9db636..e6f55301c1 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4977,8 +4977,11 @@ const EXCLUSIONS: Readonly> = { "a key-name tuple, not a schema — the exclusion list `SpecDashboardFields` and the `DashboardComponentSchema` twin both read (objectui#9736)", 'layout.zod.ts#PAGE_SPEC_EXCLUDED': "a key-name tuple, not a schema — the exclusion list `SpecPageFields` and the `PageNodeSchema` twin both read (objectui#9736)", + // objectui#11522 — no longer a union of two dialects: the spec list view's own + // rule, by reference, plus the retired keys as tombstones. The TS twin is + // compared where it is pinned, the way `ExpressionWireSchema` above is. 'objectql.zod.ts#KanbanConditionalFormattingRuleSchema': - "a union of two rule dialects (native `{ field, operator, value }` | spec `{ condition, style }`) with no `.shape` of its own — exported by objectui#7664 so the `'kanban'` arm (`complex.zod.ts#KanbanSchema`) and the `'object-kanban'` arm mirror `conditionalFormatting` from ONE rule declaration; its TS twin `KanbanConditionalFormattingRule` (`../objectql.ts`) is a type union, not a key set, and both arms' `conditionalFormatting` keys are compared where they are declared", + "the spec list view's `{ condition, style }` rule BY REFERENCE (`ListViewSchema.conditionalFormatting`'s element, `.extend()`-ed), with the condition the list view's own arm reads and the six retired keys of the native and flat-colour dialects as tombstones (objectui#11522) — not a hand-written key set; its TS twin `KanbanConditionalFormattingRule` (`../objectql.ts`) extends `SpecConditionalFormattingRule`, the same reference, and the two faces' key sets and values are pinned equal in `kanban-conditional-formatting.test.ts`; `ObjectKanbanSchema`'s `conditionalFormatting` key is compared where it is declared", }; /* ── Which pairs depend on @objectstack/spec ────────────────────────────────── */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8df8045b41..e3d3c6fe77 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -494,7 +494,6 @@ export type { ObjectCalendarSchema, ObjectKanbanSchema, KanbanConditionalFormattingRule, - KanbanNativeConditionalFormattingRule, ObjectChartSchema, ObjectGallerySchema, ObjectDataTableSchema, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index d7fc34a206..616d312d59 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -4882,8 +4882,11 @@ export interface ObjectKanbanSchema extends BaseSchema { allowCollapse?: never; /** - * Conditional formatting rules for card coloring. - * Cards are colored based on field values matching conditions. + * Conditional formatting rules for card colouring, each `{ condition, style }` + * evaluated against the card's own record; the first matching rule styles + * the card. The native `{ field, operator, value }` rule and a top-level + * colour key are refused by name (objectui#11522) — see + * {@link KanbanConditionalFormattingRule}. */ conditionalFormatting?: KanbanConditionalFormattingRule[]; @@ -5070,32 +5073,55 @@ export interface ObjectKanbanSchema extends BaseSchema { } /** - * Native (field/operator/value) conditional formatting rule for Kanban cards. - */ -export interface KanbanNativeConditionalFormattingRule { - /** Field name to check */ - field: string; - /** Operator for comparison */ - operator: 'equals' | 'not_equals' | 'contains' | 'in'; - /** Value to compare against */ - value: string | string[]; - /** Background color to apply (Tailwind class or CSS color) */ - backgroundColor?: string; - /** Border color to apply (Tailwind class or CSS color) */ - borderColor?: string; -} - -/** - * Conditional formatting rule for Kanban cards. + * Conditional formatting rule for Kanban cards — ONE dialect, the spec list + * view's `{ condition, style }`: a CEL `condition` over the card's `record.*` + * and a CSS `style` map, first matching rule wins (objectui#11522). + * + * It IS {@link SpecConditionalFormattingRule}, the protocol's rule by reference + * (its `condition` indexes `ObjectListViewSchema`'s own slot, objectui#10946), + * plus the retired keys declared `?: never`, so each is refused BY NAME: + * + * - the native comparison `{ field, operator, value, backgroundColor, + * borderColor }` (#1584 accepted it beside the spec shape; the + * `KanbanNativeConditionalFormattingRule` interface that declared it is + * gone), and + * - a colour written at the TOP LEVEL of a rule — `backgroundColor`, + * `borderColor`, `textColor` — including the "flat CEL" rule + * `{ condition, backgroundColor }`. A colour belongs inside `style` + * (`textColor` is `style.color`). + * + * Retired, not widened, with no alias window (triage ruling 5963861071 on + * objectui#11522; the producer census it rests on is recorded on that card and + * its pull request, not here). `@objectstack/spec` types `object-kanban`'s + * member by reference to the list view's, which refuses both. * - * Since #1584, kanban card styling runs on the shared CEL evaluator, so a rule - * accepts BOTH the native `{ field, operator, value }` shape and the spec - * `{ condition, style }` shape (a CEL predicate + style map) — the same - * `record.*` predicates authors use on list/grid rows. + * Respelling: `{ field: 'priority', operator: 'equals', value: 'high', + * backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", + * style: { backgroundColor: '#fee2e2' } }`. + * + * ⚠️ What did NOT narrow: the shared evaluator. `@object-ui/core`'s + * `resolveConditionalFormatting` still reads every arm, because the grid's and + * the list view's {@link ConditionalFormattingRule} still declares them; the + * board paints whatever a relay hands it. A `{ condition, style }` rule paints + * a card exactly as it did before. + * + * The zod twin is `KanbanConditionalFormattingRuleSchema` in + * `./zod/objectql.zod.ts`, each retired key a `retirementTombstone()` there. */ -export type KanbanConditionalFormattingRule = - | KanbanNativeConditionalFormattingRule - | SpecConditionalFormattingRule; +export interface KanbanConditionalFormattingRule extends SpecConditionalFormattingRule { + /** @deprecated RETIRED (objectui#11522) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + field?: never; + /** @deprecated RETIRED (objectui#11522) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + operator?: never; + /** @deprecated RETIRED (objectui#11522) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + value?: never; + /** @deprecated RETIRED (objectui#11522) — a top-level colour. Write `style: { backgroundColor }`. */ + backgroundColor?: never; + /** @deprecated RETIRED (objectui#11522) — a top-level colour. Write `style: { borderColor }`. */ + borderColor?: never; + /** @deprecated RETIRED (objectui#11522) — a top-level colour. Write `style: { color }`. */ + textColor?: never; +} /** * Object Chart Component Schema — the node `plugin-charts`' `ObjectChart` diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 04c4bbc227..4813c9f33a 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2916,30 +2916,77 @@ export const ObjectCalendarSchema = BaseSchema.extend({ /** * ObjectKanban Schema */ -// Since #1584, kanban card styling runs on the shared CEL evaluator, so a -// kanban rule accepts BOTH the native `{ field, operator, value }` shape and the -// spec `{ condition, style }` shape (a CEL predicate + style map) — matching -// list/grid `conditionalFormatting`. The type/schema now match the runtime. +/** + * The guidance a retired kanban rule key is refused with (objectui#11522). + * + * `native` keys belong to the native `{ field, operator, value }` comparison; + * `colour` keys are a colour written at the TOP LEVEL of a rule — beside a CEL + * `condition` (the "flat CEL" rule) or on the native comparison. Each message + * names the key, the retirement and the one spelling that replaces it. + */ +function kanbanRuleKeyRetired(key: string, kind: 'native' | 'colour'): string { + const lead = + kind === 'native' + ? `\`${key}\` belongs to the native kanban rule dialect \`{ field, operator, value, backgroundColor, borderColor }\`, ` + : `\`${key}\` is a colour written at the top level of the rule, `; + const into = key === 'textColor' ? '`style: { color }`' : `\`style: { ${key} }\``; + return ( + lead + + 'which `object-kanban`\'s `conditionalFormatting` no longer accepts: RETIRED (objectui#11522), with no alias window. ' + + 'A rule is `{ condition, style }` — a CEL `condition` over `record.*` and a CSS `style` map, the rule ' + + '`@objectstack/spec`\'s `ListViewSchema.conditionalFormatting` declares. ' + + (kind === 'native' + ? 'Respell `{ field: \'priority\', operator: \'equals\', value: \'high\', backgroundColor: \'#fee2e2\' }` as ' + + '`{ condition: "record.priority == \'high\'", style: { backgroundColor: \'#fee2e2\' } }` ' + + '(`not_equals` is `!=`, `contains` is `.contains(…)`, `in` is `record.f in [ … ]`).' + : `Move the colour into the rule's CSS map: ${into}.`) + ); +} + +// objectui#11522 — `object-kanban`'s `conditionalFormatting` speaks ONE rule +// dialect, the spec list view's `{ condition, style }`, and refuses the two it +// used to take BY NAME (triage ruling 5963861071: retire, not widen). // -// Exported since objectui#7664 so `complex.zod.ts`'s `KanbanSchema` (the -// `'kanban'` arm) mirrors `conditionalFormatting` with the SAME rule union as -// this `'object-kanban'` arm — one declaration of the rule, two arms. It is a -// union of two rule dialects with no `.shape` of its own, so the parity census -// EXCLUDES it rather than pairing it; its TS twin is the type union -// `KanbanConditionalFormattingRule` (`../objectql.ts`). -export const KanbanConditionalFormattingRuleSchema = z.union([ - z.object({ - field: z.string().describe('Field name to check'), - operator: z.enum(['equals', 'not_equals', 'contains', 'in']).describe('Comparison operator'), - value: z.union([z.string(), z.array(z.string())]).describe('Value to compare against'), - backgroundColor: z.string().optional().describe('Background color'), - borderColor: z.string().optional().describe('Border color'), - }), - z.object({ - condition: SpecRuleConditionSchema.describe('CEL predicate evaluated against the card record'), - style: z.record(z.string(), z.string()).describe('CSS styles applied when the condition is true'), - }), -]); +// The rule is the protocol's own: `ListViewSchema.conditionalFormatting[]`'s +// element, read BY REFERENCE through the import boundary and `.extend()`-ed — +// so it inherits that element's strictness (an undeclared key is refused with +// the spec's own "Unrecognized key(s) on this conditional formatting rule" +// message) and its `style` map, and moves with the installed spec. Two things +// are layered on top, and only two: +// +// - `condition` is `SpecRuleConditionSchema`, the list view's and the grid's +// own condition (objectui#10946): the spec slot by reference behind a +// `z.string()` arm, so a string condition is not canonicalized into an +// envelope and `''` is still accepted — the kanban rule judges a condition +// exactly as the list view's `{ condition, style }` arm does. +// - the retired keys are DECLARED and unwritable (`retirementTombstone()`), +// so each is refused at its own path with the remedy instead of being one +// more unrecognized key: the native comparison's `field` / `operator` / +// `value`, and the three top-level colour keys the shared resolver +// (`resolveConditionalFormatting`) would otherwise still paint with — +// `backgroundColor` and `borderColor` (the native rule's, and the flat CEL +// rule's), and `textColor`. `z.input` of each is `undefined`, which is the +// TS twin's `?: never`. +// +// Before this, the member was a union of the native comparison and the spec +// shape (#1584), exported by objectui#7664 for the since-retired `'kanban'` +// arm. ⚠️ The shared resolver is NOT narrowed: the grid's and the list view's +// rule union (`ConditionalFormattingRuleSchema` above) still declares every +// arm it reads, so the board itself still paints whatever a relay hands it. +// What retired is the AUTHORED kanban member. Its TS twin is +// `KanbanConditionalFormattingRule` (`../objectql.ts`); the two faces are +// pinned equal in `../__tests__/kanban-conditional-formatting.test.ts`. +const SpecListViewRuleSchema = stripImportedDefaults(SpecListViewSchema).shape.conditionalFormatting.unwrap().element; + +export const KanbanConditionalFormattingRuleSchema = SpecListViewRuleSchema.extend({ + condition: SpecRuleConditionSchema.describe('CEL predicate evaluated against the card record'), + field: retirementTombstone(kanbanRuleKeyRetired('field', 'native')), + operator: retirementTombstone(kanbanRuleKeyRetired('operator', 'native')), + value: retirementTombstone(kanbanRuleKeyRetired('value', 'native')), + backgroundColor: retirementTombstone(kanbanRuleKeyRetired('backgroundColor', 'colour')), + borderColor: retirementTombstone(kanbanRuleKeyRetired('borderColor', 'colour')), + textColor: retirementTombstone(kanbanRuleKeyRetired('textColor', 'colour')), +}); /** * The `object-kanban` board has a record source — at least one of `bind`, From 5942bd0d3fc7184d2eb865e65c49d364b9652bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:02:49 +0000 Subject: [PATCH 2/5] feat(plugin-kanban): the object-kanban registration describes the one { condition, style } rule; the kanban fixtures are respelled (objectui#11522) The registry input description for conditionalFormatting no longer teaches the native or flat-colour rule. The three plugin-kanban fixtures that authored a retired dialect write the same predicate and paint as { condition, style } and assert the same card is styled; a row pins that the whole style map reaches the card. The schema-reference row, the console member-pin prose and the one-authority test's note on the retired type name follow. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../registry-inputs-spec-parity.test.ts | 2 +- content/docs/api/schema-reference.md | 2 +- packages/plugin-kanban/src/KanbanImpl.tsx | 12 +++-- ...cturedMembersReachTheirSinks-8313.test.tsx | 46 +++++++++++++++---- ...ctFieldsIsAPropNotASchemaKey-7742.test.tsx | 5 +- ...redKeysAreDeclaredAndHonoured-8313.test.ts | 4 +- packages/plugin-kanban/src/index.tsx | 2 +- ...e-authority-per-exported-name-6273.test.ts | 12 +++-- 8 files changed, 60 insertions(+), 25 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 66e10a6e3f..e907927a1e 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2878,7 +2878,7 @@ const MEMBER_PINS: Record = { }, 'object-kanban.conditionalFormatting': { file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx', - pins: 'Members are card STYLE RULES in two accepted dialects, and BOTH reach the sink: the native `{ field, operator, value, backgroundColor }` and the spec CEL `{ condition, backgroundColor }` each colour the matching card and only it, with the sibling card in the same render as the live non-matching control so a green cannot come from two unstyled cards agreeing. ⛔ NOT an identity pin and not a wire pin: `ObjectKanban.tsx` never names this key at all (measured zero, against nine for `cardFields` in the same file) — it rides the `{ ...schema }` spread into `KanbanRenderer`, which forwards it to `KanbanImpl`\'s `getCardStyles`. That makes the pin load-bearing in a way the others are not: an edit replacing that spread with an explicit key list drops the key silently and nothing else in the repo would notice. The spec row is `z.unknown()`, so the read site is the whole member contract (objectui#8313).', + pins: 'Members are card STYLE RULES in ONE dialect since objectui#11522, the spec list view\'s `{ condition, style }`, and it reaches the sink: a rule colours the matching card and only it, a second rule aimed at the other card does the same, and the whole `style` map arrives, with the sibling card in the same render as the live non-matching control so a green cannot come from two unstyled cards agreeing. The retired native `{ field, operator, value }` and flat-colour dialects are refused by name on the types side (`packages/types/src/__tests__/kanban-conditional-formatting.test.ts`); these rows are their respellings, painting the same cards. ⛔ NOT an identity pin and not a wire pin: `ObjectKanban.tsx` never names this key at all (measured zero, against nine for `cardFields` in the same file) — it rides the `{ ...schema }` spread into `KanbanRenderer`, which forwards it to `KanbanImpl`\'s `getCardStyles`. That makes the pin load-bearing in a way the others are not: an edit replacing that spread with an explicit key list drops the key silently and nothing else in the repo would notice. The spec row is `z.unknown()`, so the read site is the whole member contract (objectui#8313).', }, 'object-kanban.data': { file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx', diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 4982c72cd0..9d59b01d63 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -1012,7 +1012,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t | `filter` | `any[]` | Query filter, forwarded verbatim as `$filter`. | | `limit` | `number` | Fetch window for the board (default 100). | | `coverImageField` | `string` | Field whose URL renders as the card cover image. | -| `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules — native `{ field, operator, value }` or spec `{ condition, style }`. | +| `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules, each `{ condition, style }` — a CEL `condition` over the card's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the card. The native `{ field, operator, value }` rule and a colour written beside `condition` (rather than inside `style`) are retired and refused by name (objectui#11522): `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. | | `navigation` | `ViewNavigationConfig` | What a card click opens — the spec's `NavigationConfig` by reference, the type `ObjectGridSchema.navigation` uses: `mode` (`page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none`) with `size`, `openNewTab` and `preventNavigation`. With the key absent a click opens the record in a drawer. `page` — and a block written without `mode`, which takes the spec's `page` default — opens the record page through the record navigator the host publishes (objectui#11293); the console publishes one on its custom pages, record pages and list views, and under a host that publishes none the click opens nothing. | > `groupField` is refused by name (objectui#7322): the renderer reads `groupBy`. diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 894f27d8e7..dec3b40112 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -127,8 +127,8 @@ const SWIMLANE_AXIS_X_PADDING = 'px-2 pl-36 sm:pl-44' // was for any importer. A re-export is not a second declaration. export type { KanbanCard, KanbanColumn } from './types' -// Card formatting accepts the native `{ field, operator, value }` shape and the -// spec `{ condition, style }` CEL shape (issue #1584) — see @object-ui/types. +// Card formatting is the spec `{ condition, style }` CEL rule — the one dialect +// `object-kanban` declares since objectui#11522 — see @object-ui/types. export type ConditionalFormattingRule = KanbanConditionalFormattingRule export interface KanbanBoardProps { @@ -157,10 +157,12 @@ export interface KanbanBoardProps { * Evaluate conditional formatting rules for a card. * Returns CSS style overrides for backgroundColor and borderColor. */ -// Card conditional formatting now delegates to the shared CEL evaluator +// Card conditional formatting delegates to the shared CEL evaluator // (issue #1584 / ADR-0058) so kanban cards, list rows, and grid rows reach the -// identical verdict. Beyond the native `{ field, operator, value }` rules the -// kanban schema declares, this also accepts spec `{ condition, style }` rules. +// identical verdict. The kanban schema declares the spec `{ condition, style }` +// rule only (objectui#11522); the evaluator is shared with the grid and the list +// view, whose rule union still declares the native and colour-key arms, so it +// still reads them — a rule a relay hands this board is painted as it was. // The host predicate scope is bound alongside the card so `features.*` / // `current_user.*` conditions resolve here exactly as they do on grid rows. function getCardStyles( diff --git a/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx b/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx index eb99f656c8..5542c1c73a 100644 --- a/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx +++ b/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx @@ -45,8 +45,9 @@ * - `grouping` — WHICH SINGLE NESTED POSITION is read, and under what * precedence. One position (`fields[0].field`), one role (the fallback for * `swimlaneField`), everything else inert. - * - `conditionalFormatting` — WHICH MEMBER DIALECTS are evaluated, and - * against what. Two dialects, per card, on the card's own record. + * - `conditionalFormatting` — WHICH MEMBER DIALECT is evaluated, and + * against what. One dialect since objectui#11522, `{ condition, style }`, + * per card, on the card's own record. * * ## The spec supplies none of it * @@ -447,21 +448,29 @@ describe('objectui#8313 — `object-kanban`.`grouping`: one nested position and }); /* -------------------------------------------------------------------------- */ -/* `conditionalFormatting` — two member dialects, per card. */ +/* `conditionalFormatting` — the `{ condition, style }` member, per card. */ /* -------------------------------------------------------------------------- */ -describe('objectui#8313 — `object-kanban`.`conditionalFormatting`: two member dialects', () => { +describe('objectui#8313 — `object-kanban`.`conditionalFormatting`: the `{ condition, style }` member', () => { const ROWS = [ { id: 'a', name: 'Alpha deal', status: 'open', owner: 'ann' }, { id: 'b', name: 'Beta deal', status: 'open', owner: 'bob' }, ]; - it('the NATIVE `{ field, operator, value }` member colours the matching card, and only it', async () => { + // ⚠️ RESPELLED, not rewritten (objectui#11522). The two rows below used to + // author the two dialects the member then accepted: the native + // `{ field: 'owner', operator: 'equals', value: 'ann', backgroundColor }` and + // the flat CEL `{ condition: "record.owner == 'bob'", backgroundColor }`. + // Both are retired and refused by name on every zod face; each row now writes + // the SAME predicate and the SAME paint as `{ condition, style }`, and asserts + // the SAME card is painted and its neighbour is not — so a respelling that + // changed which card the rule reaches would turn the row red. + it('a `{ condition, style }` member colours the matching card, and only it', async () => { const adapter = makeAdapter(); const { container } = renderBoard(adapter, { data: ROWS, conditionalFormatting: [ - { field: 'owner', operator: 'equals', value: 'ann', backgroundColor: 'rgb(1, 2, 3)' }, + { condition: "record.owner == 'ann'", style: { backgroundColor: 'rgb(1, 2, 3)' } }, ], }); @@ -474,14 +483,14 @@ describe('objectui#8313 — `object-kanban`.`conditionalFormatting`: two member expect(stylesOnCard(container, 'Beta deal')).not.toContain('background-color'); }); - it('the SPEC CEL `{ condition }` member does the same, against the card’s own record', async () => { - // The second dialect (#1584 / ADR-0058). Aimed at the OTHER card on - // purpose, so a shared fixture cannot make the two dialects look alike. + it('a second rule aimed at the OTHER card does the same, against that card’s own record', async () => { + // Aimed at the other card on purpose, so one shared fixture cannot make two + // rules look alike (#1584 / ADR-0058). const adapter = makeAdapter(); const { container } = renderBoard(adapter, { data: ROWS, conditionalFormatting: [ - { condition: "record.owner == 'bob'", backgroundColor: 'rgb(4, 5, 6)' }, + { condition: "record.owner == 'bob'", style: { backgroundColor: 'rgb(4, 5, 6)' } }, ], }); @@ -491,6 +500,23 @@ describe('objectui#8313 — `object-kanban`.`conditionalFormatting`: two member expect(stylesOnCard(container, 'Alpha deal')).not.toContain('background-color'); }); + it('the WHOLE `style` map reaches the card — `style` is the only colour channel now', async () => { + // With the top-level colour keys retired (objectui#11522), every paint + // rides `style`, so the map must arrive whole and not as one picked key. + const adapter = makeAdapter(); + const { container } = renderBoard(adapter, { + data: ROWS, + conditionalFormatting: [ + { condition: "record.owner == 'ann'", style: { backgroundColor: 'rgb(1, 2, 3)', borderColor: 'rgb(7, 8, 9)' } }, + ], + }); + + await waitFor(() => expect(container.textContent).toContain('Alpha deal')); + expect(stylesOnCard(container, 'Alpha deal')).toContain('background-color: rgb(1, 2, 3)'); + expect(stylesOnCard(container, 'Alpha deal')).toContain('border-color: rgb(7, 8, 9)'); + expect(stylesOnCard(container, 'Beta deal')).not.toContain('border-color'); + }); + it('⚠️ `ObjectKanban.tsx` never NAMES this key — it rides the `{ ...schema }` spread', () => { // The structural fact that makes the two rows above load-bearing, made // mechanical instead of left in prose. `conditionalFormatting` is the only diff --git a/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx index 5f215fffb9..9e6e0447c8 100644 --- a/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx +++ b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx @@ -76,7 +76,10 @@ import '../KanbanImpl'; /** The paint the matching rule applies — a colour no other element uses. */ const PAINT = 'rgb(255, 0, 0)'; -const RULE = [{ field: 'owner', operator: 'equals', value: 'u1', backgroundColor: PAINT }]; +// `{ condition, style }`: RESPELLED (objectui#11522) from the retired native +// `{ field: 'owner', operator: 'equals', value: 'u1', backgroundColor }` — the +// same comparison on the same relation field, the same paint. +const RULE = [{ condition: "record.owner == 'u1'", style: { backgroundColor: PAINT } }]; /** `owner` arrives EXPANDED, the way the board's own `$expand` delivers it. */ const CARD = { id: 'c1', title: 'Painted card', owner: { _id: 'u1', name: 'Ann' } }; diff --git a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts index 06c64600b0..bcf38c41b3 100644 --- a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts +++ b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts @@ -104,7 +104,9 @@ const DECLARED_STRUCTURED_KEYS = [ { key: 'conditionalFormatting', arm: 'array', - value: [{ field: 'owner', operator: 'equals', value: 'ann', backgroundColor: '#eef' }], + // `{ condition, style }`: respelled (objectui#11522) from the retired native + // `{ field: 'owner', operator: 'equals', value: 'ann', backgroundColor }`. + value: [{ condition: "record.owner == 'ann'", style: { backgroundColor: '#eef' } }], }, ] as const; diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 66e0f1bd5a..7a241642ec 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -672,7 +672,7 @@ const OBJECT_KANBAN_INPUTS: ComponentInput[] = [ { name: 'data', type: 'array', description: 'Inline records to render instead of fetching. Authoring it SUPPRESSES the board’s own query entirely. Members are records: the board reads `id` (or `_id`) as the card identity, the `groupBy` field’s value as the lane, the card-title field, `coverImageField`, and every `cardFields` entry. Records handed down by a parent view and a `bind` expression both take priority over it.' }, { name: 'cardFields', type: 'array', description: 'Record field NAMES rendered as cells on each card, in the order written. Members are bare names, not entry objects. An explicit list wins over the object’s `highlightFields` role; unlike that fallback it is NOT filtered against the object definition, so a name the object no longer declares simply renders no cell. An empty array reads as omitted.' }, { name: 'grouping', type: 'object', description: 'Only `grouping.fields[0].field` is read, and only as the FALLBACK for `swimlaneField`: it names the record field that splits the board into horizontal swimlanes when no `swimlaneField` is authored. An explicit `swimlaneField` wins. Every other position inside `grouping`, later `fields` entries included, is inert on this board.' }, - { name: 'conditionalFormatting', type: 'array', description: 'Per-card style rules, each evaluated against that card’s own record. Two member dialects are accepted: the native `{ field, operator, value, backgroundColor?, borderColor? }` and the spec CEL `{ condition, backgroundColor?, borderColor? }`. A matching rule colours that card alone. A rule comparing a relation field sees the stored foreign key rather than the expanded record.' }, { name: 'navigation', type: 'object', description: 'What a card click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens the record in a drawer. `mode` is an overlay (`drawer`, `modal`, `split`, `popover`), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `page` default. `page` opens the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, there is no record page to open and the click opens nothing. `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, `preventNavigation: true` opens nothing, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.' }, + { name: 'conditionalFormatting', type: 'array', description: 'Per-card style rules, each `{ condition, style }` — a CEL `condition` over the card’s own `record.*` and a CSS `style` map, the rule a list view declares. The first matching rule styles that card alone. A rule comparing a relation field sees the stored foreign key rather than the expanded record. The native `{ field, operator, value }` rule and a colour written beside `condition` instead of inside `style` are retired and refused by name (objectui#11522).' }, { name: 'navigation', type: 'object', description: 'What a card click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens the record in a drawer. `mode` is an overlay (`drawer`, `modal`, `split`, `popover`), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `page` default. `page` opens the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, there is no record page to open and the click opens nothing. `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, `preventNavigation: true` opens nothing, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.' }, ]; ComponentRegistry.register( diff --git a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts index 600bed9be0..0cd8a187d3 100644 --- a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts +++ b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts @@ -55,9 +55,10 @@ import { blank, scanSource } from '../js-comment-mask.mjs'; * * ⛔ **Near-spellings and derived names are NOT collisions.** The matcher * anchors the name on both ends, so `ConditionalFormattingRule` does not match - * `KanbanConditionalFormattingRule` (`packages/types/src/objectql.ts:2202`) or - * `KanbanNativeConditionalFormattingRule` (same file, :2181) — both of which - * this repository really writes, next to the contested name. + * `KanbanConditionalFormattingRule` (declared in `packages/types/src/objectql.ts`, + * next to the contested name) or `KanbanNativeConditionalFormattingRule` — a + * derived name this repository declared there until objectui#11522 retired it, + * kept below as a synthetic near-spelling. * * ## Two stated bounds, because a claim must be bounded or derivable * @@ -647,8 +648,9 @@ describe('objectui#6273 — the matcher discriminates', () => { { rel: 'packages/types/src/app.ts', source: 'export interface MenuItem { id: string }' }, { rel: 'packages/types/src/index.ts', source: "export type { MenuItem as AppMenuItem } from './app';" }, ], - // Derived names next to the contested one — both real, both in - // packages/types/src/objectql.ts. + // Derived names next to the contested one. `KanbanConditionalFormattingRule` + // is real (packages/types/src/objectql.ts); the `KanbanNative…` spelling + // was, until objectui#11522 retired it, and stays as a synthetic one. [ { rel: 'packages/types/src/objectql.ts', source: 'export interface KanbanNativeConditionalFormattingRule { a: 1 }' }, { rel: 'packages/plugin-kanban/src/x.tsx', source: 'export type ConditionalFormattingRule = { a: 1 };' }, From 02ed53150fbf1197cfb40e4079a4ac7b1def0441 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:04:29 +0000 Subject: [PATCH 3/5] chore(changeset): the kanban rule dialect retirement, and dated notes on the two pending entries it makes false (objectui#11522) Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- ...0946-expression-wire-slots-by-reference.md | 14 +++++++++ .../11522-kanban-rule-dialect-retired.md | 30 +++++++++++++++++++ .changeset/7664-kanban-arm-plugin-dialect.md | 10 +++++++ 3 files changed, 54 insertions(+) create mode 100644 .changeset/11522-kanban-rule-dialect-retired.md diff --git a/.changeset/10946-expression-wire-slots-by-reference.md b/.changeset/10946-expression-wire-slots-by-reference.md index fd348fac34..def972ddb5 100644 --- a/.changeset/10946-expression-wire-slots-by-reference.md +++ b/.changeset/10946-expression-wire-slots-by-reference.md @@ -32,3 +32,17 @@ So the sentences above that say the bulk def qualifies no record, and that the test file pins the `ast`-only fault "on both", describe the tree at objectui#10946, not the code in this release. See objectui#11322's changesets for `@object-ui/plugin-grid` and `@object-ui/types`. + +⚠️ **Dated note, 2026-10-03 — the kanban rule is no longer a union — objectui#11522.** +At this change the kanban board's rule was a union of two dialects (the native +`{ field, operator, value }` comparison and `{ condition, style }`), and the +sentence above that says "the list view's and the kanban board's rule unions +share one `condition` schema" describes that union. Now the kanban rule is ONE +object, the spec list view's `{ condition, style }` rule by reference, and the +native and flat-colour dialects are refused by name on `object-kanban`. It still +reads the same `condition` schema, so everything this entry says about the +condition (the `z.string()` first arm, the envelope, the `''` control, the +reference identity of the spec arm) still holds on `object-kanban`, and +`spec-expression-wire-slots-10946.test.ts` still pins it there, now reading +`condition` straight off the rule's shape. The rest of this entry is kept as +the reading of this change. diff --git a/.changeset/11522-kanban-rule-dialect-retired.md b/.changeset/11522-kanban-rule-dialect-retired.md new file mode 100644 index 0000000000..12c9318c40 --- /dev/null +++ b/.changeset/11522-kanban-rule-dialect-retired.md @@ -0,0 +1,30 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-kanban': minor +--- + +**BREAKING for authors of `object-kanban` card rules, released as `minor`.** `object-kanban`'s `conditionalFormatting` takes ONE rule dialect, the spec list view's `{ condition, style }`: a CEL `condition` over the card's `record.*` and a CSS `style` map. The two other dialects it used to take are retired with no alias window and refused by name (objectui#11522). + +| Rule on `object-kanban` | Before | Now | +|---|---|---| +| `{ condition, style }` | accepted | accepted, unchanged | +| native `{ field, operator, value, backgroundColor?, borderColor? }` | accepted on every face | refused at `field`, `operator`, `value` and the colour key | +| flat CEL `{ condition, backgroundColor }` (a colour beside `condition`, no `style`) | refused as a bare union failure | refused at the colour key, naming the retirement | +| `{ condition, style, backgroundColor }` | accepted by `safeValidateSchema` (the colour key was stripped from the parse, then painted by the board anyway) | refused at the colour key | +| `{ condition, style, label }` (any other undeclared key) | accepted by `safeValidateSchema` | refused as an unrecognized key, with the spec rule's own message | + +Each refusal message names the key, objectui#11522 and the `{ condition, style }` respelling, on `ObjectKanbanSchema`, `safeValidateSchema` and `StrictAnyComponentSchema` alike. + +**Respelling.** `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. `not_equals` is `!=`, `contains` is `record.f.contains(…)` and `in` is `record.f in [ … ]`. A top-level colour moves into `style`; `textColor` is `style.color`. + +**`@object-ui/types`.** + +- `KanbanConditionalFormattingRule` is now an interface that extends `SpecConditionalFormattingRule`, with `field`, `operator`, `value`, `backgroundColor`, `borderColor` and `textColor` declared `?: never`. It used to be the union of `SpecConditionalFormattingRule` and `KanbanNativeConditionalFormattingRule`. +- `KanbanNativeConditionalFormattingRule` is removed. Importing it is a compile error (TS2305). +- `KanbanConditionalFormattingRuleSchema` (`@object-ui/types/zod`) is the spec `ListViewSchema.conditionalFormatting` rule taken by reference and extended, not a union. It keeps the spec rule's strictness and its `style` map. Its `condition` is the same schema the list view's and the grid's `{ condition, style }` arm reads, so a string condition is still not canonicalized into an envelope and `''` is still accepted. The six retired keys are retirement tombstones. + +**`@object-ui/plugin-kanban`.** The `object-kanban` registration's `conditionalFormatting` input description now describes the one rule. `KanbanRendererProps.schema.conditionalFormatting` and the board's `ConditionalFormattingRule` follow the narrowed type. + +**Not changed: what the board paints.** The shared evaluator, `resolveConditionalFormatting` in `@object-ui/core`, keeps every arm, because the grid's and the list view's rule union still declares them. A `{ condition, style }` rule styles a card exactly as before. A rule that a relay hands the board, for example a list view's, is painted as before too. Only the authored `object-kanban` member narrowed. + +Pins: `packages/types/src/__tests__/kanban-conditional-formatting.test.ts` (turned around) pins both refusals on all three zod faces, the spec rule's identity and strictness, and the TS face. `ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx` and `objectFieldsIsAPropNotASchemaKey-7742.test.tsx` in `@object-ui/plugin-kanban` draw the respelled rules through the real board and assert the same cards are painted. diff --git a/.changeset/7664-kanban-arm-plugin-dialect.md b/.changeset/7664-kanban-arm-plugin-dialect.md index ade73ca36e..339c2bde73 100644 --- a/.changeset/7664-kanban-arm-plugin-dialect.md +++ b/.changeset/7664-kanban-arm-plugin-dialect.md @@ -88,3 +88,13 @@ This is a breaking change shipped as `minor`: this repository's version-alignment rule keeps objectui's major pinned to `@objectstack`'s and ships objectui's own breaking changes as `minor` with the break spelled out in the changeset body, which is what the bullets above are. + +⚠️ **Dated note, 2026-10-03 — `KanbanConditionalFormattingRuleSchema` is no longer a union — objectui#11522.** +At this change `KanbanConditionalFormattingRuleSchema` was "the rule union the +`'object-kanban'` arm already applied": the native `{ field, operator, value }` +comparison or `{ condition, style }`. Now it is one object, the spec list view's +`{ condition, style }` rule by reference, and the native rule and a top-level +colour key (`backgroundColor`, `borderColor`, `textColor`) are refused by name; +the `'kanban'` arm it was shared with has itself retired in this release +(objectui#8802). It is still exported from `@object-ui/types/zod` under the same +name. The rest of this entry is kept as the reading of this change. From 6d3b4da0954918c4061b12afac26b3dc3184e384 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:13:14 +0000 Subject: [PATCH 4/5] test(types): respell the 8801 pin's live conditionalFormatting member to { condition, style } (objectui#11522) Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../object-kanban-allow-collapse-retired-8801.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts b/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts index a9ddf0fc27..2a298a0871 100644 --- a/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts +++ b/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts @@ -193,7 +193,9 @@ describe('the positive direction — the declared members still parse', () => { ['cardFields', ['owner']], // `quickAdd` left this list when objectui#8285 retired it on this arm. ['coverImageField', 'cover'], - ['conditionalFormatting', [{ field: 'status', operator: 'equals', value: 'open' }]], + // `{ condition, style }`: respelled (objectui#11522) from the retired native + // `{ field: 'status', operator: 'equals', value: 'open' }`, now refused by name. + ['conditionalFormatting', [{ condition: "record.status == 'open'", style: { backgroundColor: '#fee2e2' } }]], ])('still accepts the live member `%s`', (key, value) => { expect(refusals({ ...NODE, [key]: value })).toEqual([]); }); From e423e415cad8b07b78dc232432f11ae8ab6bbd0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:12:29 +0000 Subject: [PATCH 5/5] chore(changeset): name KanbanConditionalFormattingRuleSchema's real locus, a module export of src/zod/objectql.zod.ts that is not on the @object-ui/types/zod barrel (objectui#11522) The new changeset's @object-ui/types bullet and the dated note on the 7664 entry both placed the schema on `@object-ui/types/zod`. At this head that barrel does not re-export it and no `exports` entry carries it; the note now says so, and records that the 7664 bullet's own locus claim does not hold in this release either. Text only; no export added. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11522-kanban-rule-dialect-retired.md | 2 +- .changeset/7664-kanban-arm-plugin-dialect.md | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.changeset/11522-kanban-rule-dialect-retired.md b/.changeset/11522-kanban-rule-dialect-retired.md index 12c9318c40..03b0f3090f 100644 --- a/.changeset/11522-kanban-rule-dialect-retired.md +++ b/.changeset/11522-kanban-rule-dialect-retired.md @@ -21,7 +21,7 @@ Each refusal message names the key, objectui#11522 and the `{ condition, style } - `KanbanConditionalFormattingRule` is now an interface that extends `SpecConditionalFormattingRule`, with `field`, `operator`, `value`, `backgroundColor`, `borderColor` and `textColor` declared `?: never`. It used to be the union of `SpecConditionalFormattingRule` and `KanbanNativeConditionalFormattingRule`. - `KanbanNativeConditionalFormattingRule` is removed. Importing it is a compile error (TS2305). -- `KanbanConditionalFormattingRuleSchema` (`@object-ui/types/zod`) is the spec `ListViewSchema.conditionalFormatting` rule taken by reference and extended, not a union. It keeps the spec rule's strictness and its `style` map. Its `condition` is the same schema the list view's and the grid's `{ condition, style }` arm reads, so a string condition is still not canonicalized into an envelope and `''` is still accepted. The six retired keys are retirement tombstones. +- `KanbanConditionalFormattingRuleSchema`, the rule schema `ObjectKanbanSchema` applies, is a module export of this package's `src/zod/objectql.zod.ts`. It is not on the `@object-ui/types/zod` barrel or on any other entry of the package's `exports` map, so it is not an import a consumer can name. It is the spec `ListViewSchema.conditionalFormatting` rule taken by reference and extended, not a union. It keeps the spec rule's strictness and its `style` map. Its `condition` is the same schema the list view's and the grid's `{ condition, style }` arm reads, so a string condition is still not canonicalized into an envelope and `''` is still accepted. The six retired keys are retirement tombstones. **`@object-ui/plugin-kanban`.** The `object-kanban` registration's `conditionalFormatting` input description now describes the one rule. `KanbanRendererProps.schema.conditionalFormatting` and the board's `ConditionalFormattingRule` follow the narrowed type. diff --git a/.changeset/7664-kanban-arm-plugin-dialect.md b/.changeset/7664-kanban-arm-plugin-dialect.md index 339c2bde73..68e1a06c4a 100644 --- a/.changeset/7664-kanban-arm-plugin-dialect.md +++ b/.changeset/7664-kanban-arm-plugin-dialect.md @@ -96,5 +96,12 @@ comparison or `{ condition, style }`. Now it is one object, the spec list view's `{ condition, style }` rule by reference, and the native rule and a top-level colour key (`backgroundColor`, `borderColor`, `textColor`) are refused by name; the `'kanban'` arm it was shared with has itself retired in this release -(objectui#8802). It is still exported from `@object-ui/types/zod` under the same -name. The rest of this entry is kept as the reading of this change. +(objectui#8802). It keeps its name, and it is a module export of +`src/zod/objectql.zod.ts` inside `@object-ui/types`, NOT an export of the +`@object-ui/types/zod` barrel. Measured at objectui#11522's change: that barrel +re-exports `KanbanCardSchema`, `KanbanColumnSchema` and `ObjectKanbanSchema` from +the kanban family and not this schema, and no entry of the package's `exports` +map carries it. So the bullet above that calls it "newly exported from +`@object-ui/types/zod`" does not hold in this release either; whether it held at +objectui#7664's own commit was not measured. The rest of this entry is kept as +the reading of this change.