From a1da4f875eae858a31935507f3bfa5972850b337 Mon Sep 17 00:00:00 2001 From: "claude[bot]" Date: Tue, 15 Sep 2026 01:21:27 +0000 Subject: [PATCH] feat(types): declare the four action keys the two renderers read off `UIActionSchema` `disabled`, `recordIdField`, `resultDialog` and `undoable` were read at eight sites across `action-button.tsx` and `action-icon.tsx` through `as any`, and none was a declared member of the type. Each key was decided on its own reading with `checker.getPropertyOfType` on the static type of `schema` BEFORE the cast, never with a grep: all four are DECLARED on `@objectstack/spec`'s `ActionSchema`, the schema this interface mirrors, so all four align the mirror. The casts are removed in the same change, because a cast at the read site defeats the declaration while a membership instrument still reports the member as present. A new pin re-derives the contract side every run and goes red if a cast returns. Removing the `resultDialog` cast made the compiler name a second defect it had been hiding: `@object-ui/core`'s `ResultDialogSpec` hand-copies the contract's block and has drifted on three `I18nLabel` members. That repair is in another package and turns on an i18n-resolution decision, so it is filed separately; the write side carries a documented, pinned narrowing meanwhile. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../8648-ui-action-four-undeclared-keys.md | 57 +++ .../action-undeclared-keys-8648.test.ts | 327 ++++++++++++++++++ .../src/renderers/action/action-button.tsx | 34 +- .../src/renderers/action/action-icon.tsx | 14 +- packages/types/src/ui-action.ts | 82 ++++- 5 files changed, 500 insertions(+), 14 deletions(-) create mode 100644 .changeset/8648-ui-action-four-undeclared-keys.md create mode 100644 packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts diff --git a/.changeset/8648-ui-action-four-undeclared-keys.md b/.changeset/8648-ui-action-four-undeclared-keys.md new file mode 100644 index 0000000000..9afe96d1e3 --- /dev/null +++ b/.changeset/8648-ui-action-four-undeclared-keys.md @@ -0,0 +1,57 @@ +--- +'@object-ui/types': minor +'@object-ui/components': patch +--- + +`UIActionSchema` declares the four keys the two action renderers were reading +through `as any` — `disabled`, `recordIdField`, `resultDialog`, `undoable` +(objectui#8648, the objectui#8327 family's `components` card). + +**Four keys, four separate readings, and the contract gave the same answer four +times.** The exit per key is settled by one question — does `@objectstack/spec` +declare this key, and on WHICH schema? — because `@object-ui/types` is a MIRROR +of the platform contract and not an authority over it. All four came back +DECLARED on the contract's own `ActionSchema`, which is the schema +`UIActionSchema` mirrors and the one both renderers annotate `schema` with. ⇒ +ALIGN THE MIRROR, four times. ⛔ Not one answer forced onto four keys: the +census asks once per key and its inline sibling is where the same question comes +back *no*. + +Every verdict came from `checker.getPropertyOfType` on the static type of +`schema` BEFORE the cast, never from a grep (objectui#8410 is the standing card +that grep-shaped absence claims are unsound). ⭐ `disabled` is the one worth +naming: a word-frequency screen over the UI contract answers "present" for it +loudly, and that reading decides nothing — the contract also declares `disabled` +on surfaces these renderers have nothing to do with, and refuses it on its own +inline action schema. The per-schema reading is what separates those, and it is +re-derived on every run rather than written down +(`packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts`). + +**This is an alignment, not a widening past the contract.** The accept set of +this face moves to the platform's and never beyond it: a document that was +already valid everywhere else stops being refused here with `TS2353`. Nothing +that worked stops working. `disabled` inherits all three arms the contract +accepts — literal boolean, raw CEL string, and a `{ dialect, source }` envelope +— by derivation rather than by hand, so it cannot drift; `@object-ui/core`'s +`ActionDef` has derived the same key from the same spec type all along, and this +is the READ side of that pair, which had no declaration to land in. + +**No runtime behaviour changes.** All eight read sites were already honoured; +what changes is that the compiler now checks them. ⚠️ And declaring a key is +not enough on its own — a cast at the read site defeats the declaration while a +membership instrument still reports the member as present, so the casts are +removed in the same change and the pin goes red if one returns. + +**A second defect the `as any` was hiding, named here and filed rather than +fixed.** Removing the read-side cast on `resultDialog` made the compiler report +that `@object-ui/core`'s `ResultDialogSpec` — whose own docblock claims it +mirrors the contract's block — hand-writes `title` / `description` / +`acknowledge` as `string` where the contract declares `I18nLabel`. One `as any` +was hiding two defects at once: a missing declaration on the read side and a +drifted type on the write side. The repair lands in `@object-ui/core` plus the +dialog's own resolver and turns on an i18n-resolution decision, so it is filed +as objectui#9542 and ⛔ not guessed at here. Meanwhile the WRITE carries a +narrowing assertion to `ActionDef['resultDialog']` at both forward sites — +strictly narrower than the `as any` it replaces, since every other member of the +forward literal is compiler-checked again — documented as a ledger entry and +pinned so it cannot regress to `as any` and cannot outlive its cause in silence. diff --git a/packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts b/packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts new file mode 100644 index 0000000000..41132aa975 --- /dev/null +++ b/packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts @@ -0,0 +1,327 @@ +/** + * 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#8648 — the four keys the two action renderers read off + * `UIActionSchema` without a declaration: `disabled`, `recordIdField`, + * `resultDialog`, `undoable` (the objectui#8327 family's `components` card). + * + * This file is the instrument for every claim the ruling rests on, so each is + * re-derived on every run instead of being written down once (AGENTS.md #9). + * + * ## The rule that chose the exit, and why it chose the same one four times + * + * `@object-ui/types` is a MIRROR of `@objectstack/spec`, not an authority, so + * declaring a key the platform does not declare would make this repo accept + * what the platform refuses. The question per key is therefore: does the + * contract declare it, and on WHICH schema? Both halves matter — a token that + * exists somewhere under the UI contract is not a declaration on the schema a + * given read's node maps to. + * + * All four came back DECLARED on `@objectstack/spec`'s `ActionSchema`, which is + * the schema `UIActionSchema` mirrors and the one both renderers annotate their + * `schema` prop with. ⇒ ALIGN THE MIRROR, four times, each on its own reading. + * The four verdicts agreeing is a RESULT here, not a shortcut: the census below + * asks the question once per key, and its inline sibling — where two of the + * four are absent — is what proves the question can come back "no". + * + * ## What each leg can and cannot prove + * + * - The `Equal` legs are compiled by `tsc -p tsconfig.test.json` and by + * nothing else — vitest strips types. They are the ONLY half that can + * observe the declaration itself, because what was wrong before this card + * was a TypeScript-only absence: the reads compiled through an `as any` + * cast and behaved identically at runtime. + * - The census legs read the INSTALLED `@objectstack/spec` artifact, over the + * contract's own action schemas. They are the PREMISE of the alignment, + * never its evidence: they were green before this card and are green after. + * They earn their place by going RED the day the platform retires one of + * the four — which is the signal that this mirror owes an update. + * - The source-text legs read the two renderers through the shared comment + * mask, so a re-introduced `as any` is caught by a run that never + * type-checks. ⭐ That negative is load-bearing and is the lesson + * objectui#8649 paid for: a cast at the read site defeats a declaration + * that a MEMBERSHIP instrument (`getPropertyOfType` on the binding, which + * unwraps the cast) still reports as present. Declaring without un-casting + * is an inert declaration that pins green. + * - Every negative leg carries a control that varies ONLY the claim. + * - The last block is a LEDGER, not a pin on a good state. Un-casting the + * `resultDialog` READ made the compiler name a second, separate defect the + * `as any` had been hiding: `@object-ui/core`'s `ResultDialogSpec` is a + * hand copy of the contract's block whose `title` / `description` / + * `acknowledge` are `string` where the contract says `I18nLabel`. That fix + * is in another package and needs an i18n-resolution decision, so it is + * filed as objectui#9542 and the WRITE carries a narrowing assertion here + * meanwhile. The ledger leg asserts that workaround is still exactly what + * it says it is — so it cannot quietly become `as any` again, and it goes + * red (as an unused entry) when objectui#9542 lands and the assertions go. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +// ⛔ NOT `import * as` from '@objectstack/spec/ui' — the repo's +// `no-restricted-imports` rule refuses the namespace form by name, and named +// imports make the census population a DECLARED set rather than "whatever the +// module happens to export". +import { ActionSchema, InlineActionSchema, type Action } from '@objectstack/spec/ui'; +import type { UIActionSchema } from '@object-ui/types'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../../../scripts/js-comment-mask.mjs'; + +/** Local annotation, since the import above is untyped — the call site stays checked. */ +const mask: (source: string) => string = maskComments; + +/** Rooted at THIS file, never at `process.cwd()` — the two differ per invocation. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const RENDERERS = join(HERE, '..'); + +/** The four keys this card rules on, and the renderer files each is read in. */ +const CARD_KEYS = { + disabled: ['action-button.tsx', 'action-icon.tsx'], + recordIdField: ['action-button.tsx'], + resultDialog: ['action-button.tsx', 'action-icon.tsx'], + undoable: ['action-button.tsx'], +} as const; + +/* ── Type-level pins (compiled by `tsc -p tsconfig.test.json`) ─────────────── */ + +/** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */ +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** The only assertion form used here — its constraint is what refuses `false`. */ +type Expect = T; + +/* Direction proofs: a broken instrument makes THIS file red. */ + +// @ts-expect-error objectui#8648 — `Expect` must refuse `false`. Widen its constraint and this directive goes unused (TS2578). +type _ExpectRefusesFalse = Expect; + +// @ts-expect-error objectui#8648 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through. +type _EqualRefusesNever = Expect>; + +// @ts-expect-error objectui#8648 — `any` must NOT read as equal to `true`. `any` is exactly what the `as any` casts produced at all eight read sites. +type _EqualRefusesAny = Expect>; + +/* + * Exit "align the mirror", all four keys. Each was RED before objectui#8648 + * with `TS2339` — the member did not exist on this face at all. + * + * Derived against the contract rather than spelled out, so the pin cannot + * outlive a spec change: if `@objectstack/spec` moves one of these types, this + * goes red here instead of drifting silently. + */ +export type _DisabledMirrorsSpec = Expect>; +export type _RecordIdFieldMirrorsSpec = Expect< + Equal +>; +export type _ResultDialogMirrorsSpec = Expect< + Equal +>; +export type _UndoableMirrorsSpec = Expect>; + +/** + * Spelled out as well as derived for the two keys with a simple type. `Equal` + * against the contract alone would also be satisfied if BOTH faces drifted to + * the same wrong type. + */ +export type _RecordIdFieldIsOptionalString = Expect< + Equal +>; +export type _UndoableIsOptionalBoolean = Expect< + Equal +>; + +/** + * `disabled` accepts all three arms the contract accepts — literal boolean, raw + * CEL string, and a `{ dialect, source }` envelope. Written as assignments + * rather than as an `Equal` against a hand-spelled union, because the envelope + * arm's own spelling is the contract's to change. + */ +const _disabledAcceptsBoolean: UIActionSchema['disabled'] = true; +const _disabledAcceptsCel: UIActionSchema['disabled'] = 'record.locked'; +const _disabledAcceptsEnvelope: UIActionSchema['disabled'] = { + dialect: 'cel', + source: 'record.locked', +}; + +/** + * The card's repro as a literal: a spec-valid, renderer-honoured action that + * `tsc` refused with `TS2353` before this card, on all four keys at once. + */ +const _allFourAccepted: UIActionSchema = { + name: 'regenerate_secret', + label: 'Regenerate secret', + type: 'api', + disabled: 'record.locked', + recordIdField: 'external_id', + resultDialog: { title: 'New secret', description: 'Copy it now — it is shown once.' }, + undoable: true, +}; + +/* ── Runtime legs ─────────────────────────────────────────────────────────── */ + +/** + * Every key name declared on `schema`, unwrapping the wrappers a published zod + * artifact uses, or `null` when no object shape is reachable at all. + * + * ⚠️ Written as a bounded BREADTH-first walk, and that is not incidental. Both + * action schemas are `z.ZodPipe` whose `_def` holds `{ type, in, out }`, and the + * two put the object on OPPOSITE sides: `ActionSchema` reaches it through `in`, + * `InlineActionSchema` — a `preprocess` — through `out`, because its `in` is the + * transform. A walker that follows one edge reads the other schema as + * unwalkable, which is a silent census cut, not an error: every absence reading + * taken through it would be vacuous. The calibration leg below is what turns + * that into a red run, and it is why `_def.type` is NOT a hop candidate — in + * zod 4 it is the type NAME (`'pipe'`), a string. + */ +function objectShapeKeys(schema: unknown): string[] | null { + type Node = { + shape?: unknown; + _def?: { shape?: unknown; in?: unknown; out?: unknown; innerType?: unknown }; + }; + let frontier: unknown[] = [schema]; + for (let depth = 0; depth < 6 && frontier.length > 0; depth += 1) { + const next: unknown[] = []; + for (const raw of frontier) { + // ⚠️ zod 4 schemas are CALLABLE, so a `typeof !== 'object'` guard here is + // not a type check — it is another silent census cut (objectui#8649 + // measured that one dropping 96 of 115 schemas). + if (!raw || (typeof raw !== 'object' && typeof raw !== 'function')) continue; + const node = raw as Node; + let shape: unknown; + try { + shape = node.shape ?? node._def?.shape; + } catch { + shape = undefined; + } + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; + if (resolved && typeof resolved === 'object') return Object.keys(resolved); + next.push(node._def?.in, node._def?.out, node._def?.innerType); + } + frontier = next; + } + return null; +} + +/** The contract's own action schemas, by the name each is published under. */ +const CONTRACT_SCHEMAS = { ActionSchema, InlineActionSchema } as const; + +/** Which of the contract's action schemas declare `key`. Derived every run. */ +function declaringActionSchemasOf(key: string): string[] { + return Object.entries(CONTRACT_SCHEMAS) + .filter(([, schema]) => objectShapeKeys(schema)?.includes(key)) + .map(([name]) => name) + .sort(); +} + +const maskedSource = (file: string): string => mask(readFileSync(join(RENDERERS, file), 'utf8')); + +/** A cast standing between `schema` and one of the four keys. */ +const castBefore = (key: string): RegExp => + new RegExp(String.raw`\(\s*schema\s+as\s+\w+\s*\)\s*\.\s*${key}\b`); + +/** The write-side narrowing this card had to leave behind — see objectui#9542. */ +const LEDGERED_WRITE_NARROWING = /resultDialog:\s*schema\.resultDialog as ActionDef\['resultDialog'\]/; + +/** The spelling it must never regress to. */ +const WRITE_SIDE_ANY = /resultDialog:\s*schema\.resultDialog as any/; + +/** The un-cast read, which must still be there — a declaration over a dead read is a hole. */ +const uncastRead = (key: string): RegExp => new RegExp(String.raw`(? { + it('walks both contract schemas and discriminates, so the readings below are readings', () => { + // Calibration in both directions: an unwalkable schema would make every + // "declared" reading vacuous, and an everything-set would make them + // unfalsifiable. + for (const [name, schema] of Object.entries(CONTRACT_SCHEMAS)) { + expect(objectShapeKeys(schema), `${name} could not be walked`).not.toBeNull(); + expect(objectShapeKeys(schema)!.length).toBeGreaterThan(2); + } + expect(declaringActionSchemasOf('label')).toEqual(['ActionSchema', 'InlineActionSchema']); + // ⛔ A negative control must be verified zero in the corpus being searched, + // never inherited: the token triage used for this family is in use as two + // other tests' own negative control in this tree, so it is nonsense no more. + expect(declaringActionSchemasOf('wwvv_nonexistent_key_8648')).toEqual([]); + }); + + for (const key of Object.keys(CARD_KEYS)) { + it(`\`${key}\` IS declared by the contract, on \`ActionSchema\``, () => { + // The reading that put "declare" ON the table for this key — and the one + // that goes RED the day the platform retires it. + expect(declaringActionSchemasOf(key)).toContain('ActionSchema'); + }); + } + + it('the contract answers "no" on the inline sibling — the census is per-SCHEMA, not per-token', () => { + // ⭐ Why a word-frequency screen over the contract cannot decide this card. + // `recordIdField` and `undoable` are absent from the inline pick list while + // present on `ActionSchema`, and spec's own `inline-action.test.ts` asserts + // that exclusion from the other side. A token screen reads "present" for + // both surfaces and is wrong about exactly this. + const inlineKeys = objectShapeKeys(InlineActionSchema)!; + expect(inlineKeys).not.toContain('recordIdField'); + expect(inlineKeys).not.toContain('undoable'); + // Calibration of the inline reading itself, so the two absences above are + // not an empty-set artefact. + expect(inlineKeys).toContain('label'); + }); +}); + +describe('objectui#8648 — the mirror declaration reaches the read sites (NOT inert)', () => { + it('the cast matcher can fire, so the negative legs below are readings', () => { + // The controls vary ONLY the claim: the same read, re-cast and un-cast. + expect(castBefore('disabled').test('toPredicateInput((schema as any).disabled)')).toBe(true); + expect(castBefore('disabled').test('toPredicateInput(schema.disabled)')).toBe(false); + expect(uncastRead('resultDialog').test('resultDialog: schema.resultDialog,')).toBe(true); + expect(uncastRead('resultDialog').test('resultDialog: (schema as any).resultDialog,')).toBe(false); + }); + + for (const [key, files] of Object.entries(CARD_KEYS)) { + for (const file of files) { + it(`${file} reads \`${key}\` UN-CAST, so the declaration is enforced there`, () => { + const source = maskedSource(file); + // Proof the file was read and masked, so the absence below is about the + // spelling and not about an empty string. + expect(source).toMatch(/UIActionSchema/); + // Liveness: the read is still here at all. A declaration guarding a + // read that has gone is a stale entry, not a pass. + expect(source).toMatch(uncastRead(key)); + // The guard, and the load-bearing half. Comments are masked before this + // runs, so the spelling quoted in this file's own prose — and in the + // renderers' — cannot satisfy or defeat it. + expect(source).not.toMatch(castBefore(key)); + }); + } + } +}); + +describe('objectui#8648 — the `resultDialog` write-side narrowing is a LEDGER entry, not a fix', () => { + it('both matchers can fire, so the legs below are readings', () => { + const narrowed = "resultDialog: schema.resultDialog as ActionDef['resultDialog'],"; + expect(LEDGERED_WRITE_NARROWING.test(narrowed)).toBe(true); + expect(WRITE_SIDE_ANY.test(narrowed)).toBe(false); + expect(WRITE_SIDE_ANY.test('resultDialog: schema.resultDialog as any,')).toBe(true); + }); + + for (const file of CARD_KEYS.resultDialog) { + it(`${file} narrows the \`resultDialog\` WRITE to \`ActionDef\` and never to \`any\``, () => { + const source = maskedSource(file); + // The entry is live. When objectui#9542 lands, `ResultDialogSpec` derives + // from the contract, the assertion is deleted, and THIS goes red — which + // is the point: a workaround must not outlive its cause in silence. + expect(source).toMatch(LEDGERED_WRITE_NARROWING); + // ⛔ The spelling that hid two defects at once must not come back. `as + // any` here would re-swallow the `ResultDialogSpec` drift AND make the + // mirror declaration inert at this site in one stroke. + expect(source).not.toMatch(WRITE_SIDE_ANY); + }); + } +}); diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index bf162192d0..83edd648eb 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -122,8 +122,11 @@ const ActionButtonRenderer = forwardRef< // `enabled`), so a spec-authored `disabled` guard did nothing (#1885, // ADR-0049). We now consume `disabled` as the primary control and keep the // legacy non-spec `enabled` as a deprecated fallback so existing metadata - // keeps working. - const isDisabled = useCondition(toPredicateInput((schema as any).disabled), recordData); + // keeps working. Uncast since objectui#8648: the mirror declares `disabled` + // by derivation from the contract, so the three arms the spec accepts + // (boolean, raw CEL, `{ dialect, source }` envelope) are the compiler's + // business here instead of `any`'s. + const isDisabled = useCondition(toPredicateInput(schema.disabled), recordData); const isEnabled = useCondition(toPredicateInput(schema.enabled), recordData); // Resolve icon @@ -213,9 +216,12 @@ const ActionButtonRenderer = forwardRef< refreshAfter: schema.refreshAfter, // Forward `undoable` (and the row id field) so update actions can // offer an Undo affordance — without this the flag is dropped and the - // handler never builds the undo operation. - undoable: (schema as any).undoable, - recordIdField: (schema as any).recordIdField, + // handler never builds the undo operation. Both uncast since + // objectui#8648: `@objectstack/spec`'s `Action` declares each, so the + // mirror declares each, and the forward is compiler-checked against + // `ActionDef` instead of arriving as `any`. + undoable: schema.undoable, + recordIdField: schema.recordIdField, // Forward the placement declaration — the console runtime uses it to // tell record-scoped actions (also mounted on rows) from pure // object-level toolbar actions when no row is selected (#2210). @@ -225,7 +231,21 @@ const ActionButtonRenderer = forwardRef< // exactly once (2FA setup, OAuth client_secret, regenerated // backup codes). Without this forward the ActionRunner falls // back to the success toast and the user loses the value. - resultDialog: (schema as any).resultDialog, + // + // The READ is uncast since objectui#8648: `resultDialog` is declared + // on the mirror, so the compiler types it as the contract's own + // block. What the assertion narrows is the WRITE, and it is a + // ledgered workaround, not a shrug — removing the read-side `as any` + // is what made the compiler name it. `ActionDef['resultDialog']` is + // `@object-ui/core`'s hand-written `ResultDialogSpec`, whose own + // docblock claims it mirrors the contract's block and does not: + // `title` / `description` / `acknowledge` are `string` there and + // `I18nLabel` in the contract, so a contract-valid inline locale map + // reaches the dialog as an object. Filed as objectui#9542; the fix is + // in `@object-ui/core` plus the dialog's own resolver and is outside + // this card's declared surface. ⛔ Never widen this back to `as any` — + // that spelling hid this AND the missing declaration at once. + resultDialog: schema.resultDialog as ActionDef['resultDialog'], // Declared post-success navigation — spec's closed strict // `{ navigate, openIn }` block, authorable on `ActionSchema` since // @objectstack/spec 17.1.0 (objectui#5328). The runner reads it off @@ -300,7 +320,7 @@ const ActionButtonRenderer = forwardRef< // "the node gate said disable, or this renderer's own gate did, or an // execution is in flight" — one carrier, three sources, no re-declare. disabled={hostDisabled || ( - hasDeclaredVisibilityGate((schema as any).disabled) + hasDeclaredVisibilityGate(schema.disabled) ? isDisabled : hasDeclaredVisibilityGate(schema.enabled) ? !isEnabled diff --git a/packages/components/src/renderers/action/action-icon.tsx b/packages/components/src/renderers/action/action-icon.tsx index c9691d9bba..978cac79bf 100644 --- a/packages/components/src/renderers/action/action-icon.tsx +++ b/packages/components/src/renderers/action/action-icon.tsx @@ -95,8 +95,9 @@ const ActionIconRenderer = forwardRef< const isVisible = useCondition(toPredicateInput(schema.visible), recordData); // Spec `disabled` (boolean | CEL — disabled when TRUE) primary, legacy // non-spec `enabled` fallback (#1885 follow-through — only action-button - // was wired; this renderer ignored a spec-authored `disabled`). - const isDisabledPred = useCondition(toPredicateInput((schema as any).disabled), recordData); + // was wired; this renderer ignored a spec-authored `disabled`). Uncast + // since objectui#8648 — see `action-button.tsx` for the reading. + const isDisabledPred = useCondition(toPredicateInput(schema.disabled), recordData); const isEnabled = useCondition(toPredicateInput(schema.enabled), recordData); const Icon = resolveIcon(schema.icon); @@ -152,8 +153,11 @@ const ActionIconRenderer = forwardRef< toast: schema.toast, // See action-button.tsx — the one-shot reveal spec (2FA setup, fresh // OAuth secret). Without it the runner falls back to the success - // toast and the value the user was meant to copy is gone. - resultDialog: (schema as any).resultDialog, + // toast and the value the user was meant to copy is gone. The READ is + // uncast since objectui#8648; the write-side assertion is the same + // ledgered `ResultDialogSpec` drift `action-button.tsx` documents + // (filed as objectui#9542). ⛔ Never widen it back to `as any`. + resultDialog: schema.resultDialog as ActionDef['resultDialog'], // See action-button.tsx — the declared post-success hop // (objectui#5493). The runner reads it off the forwarded def; dropped // here the action succeeds and the authored navigation never runs. @@ -200,7 +204,7 @@ const ActionIconRenderer = forwardRef< // reason to disable, never a reason to enable — `SchemaRenderer` emits // `true` or `undefined`, never `false`. See `action:button`. disabled={hostDisabled || ( - hasDeclaredVisibilityGate((schema as any).disabled) + hasDeclaredVisibilityGate(schema.disabled) ? isDisabledPred : hasDeclaredVisibilityGate(schema.enabled) ? !isEnabled diff --git a/packages/types/src/ui-action.ts b/packages/types/src/ui-action.ts index 6880739d78..a15dc352d3 100644 --- a/packages/types/src/ui-action.ts +++ b/packages/types/src/ui-action.ts @@ -725,6 +725,37 @@ export interface UIActionSchema { /** Whether to refresh data after execution */ refreshAfter?: boolean; + + /** + * Offer an Undo affordance after a single-record update action succeeds. + * + * Declared here as an ALIGNMENT (objectui#8648). Before this, `action:button` + * forwarded it as `(schema as any).undoable`, and + * `checker.getPropertyOfType` on the static type of `schema` BEFORE that cast + * reported no declared member — while the same instrument reports `undoable` + * DECLARED on `@objectstack/spec`'s `Action`, the schema this interface + * mirrors. `@object-ui/core`'s `ActionDef` has declared the write side all + * along; only this read side was missing, so the forward was typed `any` in + * both directions. + * + * Reachability is unchanged and is not what this declares: the runtime reads + * the key only under a `rowRecord` guard that `action:button` never seeds, + * which `check:action-forward-parity`'s JUSTIFIED table records for the three + * sibling surfaces. Declaring the key does not make it reachable; it makes + * the forward compiler-checked. + */ + undoable?: SpecAction['undoable']; + + /** + * Row field whose value seeds `recordIdParam` — defaults to `id` on the + * platform side. + * + * Declared here as an ALIGNMENT (objectui#8648), on the same reading as + * {@link undoable}: not a declared member of the pre-cast `schema` type at + * `action-button.tsx`, and DECLARED on the contract's `Action`. Typed by + * derivation so the default-bearing spelling cannot drift from the spec. + */ + recordIdField?: SpecAction['recordIdField']; /** Toast notification configuration */ toast?: { @@ -737,11 +768,58 @@ export interface UIActionSchema { /** Toast duration in milliseconds */ duration?: number; }; - + + /** + * One-shot reveal dialog for an action whose response is shown exactly once — + * 2FA setup, an OAuth `client_secret`, regenerated backup codes. Both action + * renderers forward it; without the forward the runner falls back to the + * success toast and the value the user was meant to copy is gone + * (objectui#3646). + * + * Declared here as an ALIGNMENT (objectui#8648). `checker.getPropertyOfType` + * on the pre-cast `schema` type at `action-button.tsx` and `action-icon.tsx` + * reported no declared member; the same instrument reports `resultDialog` + * DECLARED on `@objectstack/spec`'s `Action`. ⚠️ And NOT on its inline + * sibling — spec's own `inline-action.test.ts` asserts the key is not + * inline-authorable — which is why "the token exists under the UI contract" + * is not the question this settles. + */ + resultDialog?: SpecAction['resultDialog']; + // === Conditional === - + /** Expression controlling visibility (e.g., "status === 'draft'") */ visible?: string; + + /** + * Predicate DISABLING the control — TRUE means disabled. The primary gate on + * both action renderers since #1885 / ADR-0049; {@link enabled} below is the + * legacy non-spec fallback kept so existing metadata keeps working. + * + * Declared here as an ALIGNMENT (objectui#8648), and this key is the one the + * card warned about. A word-frequency screen over the UI contract answers + * "present" for `disabled` loudly (67 hits at triage) and that reading does + * not decide anything: what decides it is `checker.getPropertyOfType` on the + * actual declaring type. Taken on the static type of `schema` BEFORE the cast + * — `UIActionSchema & { type: string; className?: string | undefined; + * actionType?: string | undefined; }` — it reported NO declared member at + * either renderer; taken on `@objectstack/spec`'s `Action` it reports + * DECLARED. Taken on spec's inline sibling it reports ABSENT, which is the + * reading a token screen cannot produce. + * + * Typed by derivation so the three accepted arms — literal boolean, raw CEL + * string, and a `{ dialect, source }` envelope — cannot drift from the + * contract. `packages/core`'s `ActionDef` already derives the same key from + * the same spec type and pins all three arms in + * `actions/__tests__/actionKeys.types.test.ts`; this is the read side of that + * pair, which had no declaration to land in. + * + * ⚠️ Not to be confused with the `disabled` PROP on `ActionButtonProps` / + * `ActionIconProps` (objectui#9131): that one is the host's EVALUATED + * verdict, a `boolean`, and lives on the renderer's props bag. This one is + * the author's predicate and lives on the action. + */ + disabled?: SpecAction['disabled']; /** Expression controlling enabled state (e.g., "hasPermission('edit')") */ enabled?: string;