Skip to content

feat(console): a greyed action says the reason its author declared - #26869

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-yxepsj
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-yxepsj

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #26619 — the console half. The spec half, ActionSchema.disabledReason, landed in #26786.

Clause-②: no
Metadata-protocol: none
Skills-docs: #26790

维护者速读

  • 改了什么: 置灰的动作按钮现在显示作者写的 disabledReason(悬停提示、读屏描述、菜单项第二行),没写或写了空白时仍显示「对此记录不可用」。覆盖记录页头(含 ⋯ 溢出菜单)、记录区块按钮栏、声明动作栏,以及 action:button / action:icon / action:group / action:menu 四个渲染器。原因文案走和动作其他文案相同的翻译路径:先取翻译包 objects.对象._actions.动作.disabledReason,再取作者的内联多语言映射,最后取作者原文。
  • 为什么改: 裁决 A(6442737825)的第二步。规格已在 feat(spec): a greyed action says why — disabledReason beside disabled #26786 落地,这个 PR 让控制台真正读它,hotclm 的送审 / 发起签署 / 生效能说清缺什么。
  • 风险与回滚: 不写这个键的动作显示不变。只有声明的 disabled 判断为真时才显示原因;动作执行中、宿主禁用的按钮仍不给原因。纯展示,不参与服务端准入。回滚就是 revert 本 PR。
  • 先看: ui/packages/react/src/hooks/useActionTextLocalizer.ts(解析原因文案),ui/packages/components/src/renderers/action/disabled-reason.tsx(四个渲染器的共用钩子),packages/spec/liveness/action.json(存活账本改为 live)。
  • 两分钟验证: cd ui && pnpm exec vitest run packages/components/src/renderers/action/__tests__/action-disabled-reason-26619.test.tsx,预期全部通过:写了原因显示原因,中文环境取内联映射的中文,不写时仍是默认句。
  • 需要你做的: 无,席位落地。

What changes

The readers. While an action's DECLARED disabled predicate is TRUE, each surface shows its declared reason where it showed actions.notAvailableForRecord. That generic sentence stays the answer when the action declares none, or a blank one. The surfaces where nothing earns a reason are unchanged: a button greyed while its own action runs, the host's forwarded disabled, the host's Edit / Delete, and the legacy enabled leg.

  • ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor: the record header, inline and in the ⋯ overflow.
  • ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx (QuickActionButton): the record section bar.
  • ui/packages/app-shell/src/views/DeclaredActionsBar.tsx (DeclaredActionButton): the declared actions bar, button and override-menu item. The reason comes off the same localized copy as the label.
  • ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason: the four action:* renderers. The hook takes the declared reason as a second argument, and action-button.tsx, action-icon.tsx, action-group.tsx (inline and dropdown) and action-menu.tsx pass it.

Translation. The reason is an I18nLabel and is resolved like the action's other copy:

  • useObjectLabel (@object-ui/i18n) gains actionDisabledReason(objectName, actionName, fallback?), the actionSuffixes pattern: objects.<o>._actions.<a>.disabledReason, or globalActions.<a>.disabledReason with no key object. It reads the same two addresses translateAction and os i18n extract use.
  • useActionTextLocalizer (@object-ui/react) collapses a declared reason's locale map to the active language and asks the bundle, but only when the action declares the key. That is the confirmText rule: a bundle cannot give a reason to an action whose author wrote none, so that action keeps the default. The record header, the section bar and the declared actions bar already run their actions through it.
  • The four action:* renderers read the action as authored (they do not localize label through the bundle either), so the inline locale map is the translation they honour. useDisabledReason collapses it with pickLocalized.

Types. UIActionSchema.disabledReason (@object-ui/types), derived from the spec (SpecAction['disabledReason']), beside disabled.

Spec side.

  • packages/spec/liveness/action.json disabledReason: planned → live, evidenceScope: in-repo, with the readers above as evidence. authorWarn and authorHint are dropped in the same edit, so a warned live row cannot red check:liveness. liveness/state-counts/action.md was regenerated.
  • packages/lint/src/lint-liveness-properties.test.ts: the [Decision] A greyed-out record action can only say 对此记录不可用 — re-ask for an authored disabled reason now that a second app needs it (re-file per #24034 B′) #26619 block becomes a silence pin. The key is authored beside a property that still warns (object.externalSharingModel, dead) in the same call, so the silence is a reading. The block also asserts the shipped row is live, carries no warning, and cites containers.tsx#disabledReasonFor.
  • The two .describe() texts (action.zod.ts, translation.zod.ts) and content/docs/protocol/objectui/actions.mdx no longer say that nothing shows the key. Four content/docs/references/** pages were regenerated by check:generated --fix.

Changeset. .changeset/26619-console-disabled-reason.md, every moved package at patch: Metadata-protocol: none, because nothing an author can write changes (AGENTS.md step 3). @objectstack/lint changes a test only.

Tests

  • ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.disabledReason-26619.test.tsx (6): authored string, bundle outranks authored, locale map collapsed (zh), the globalActions address, no key added when the action declares none even with a bundle entry, a nameless action's map collapsed.
  • ui/packages/components/src/renderers/action/__tests__/action-disabled-reason-26619.test.tsx: for the three button renderings, the declared string as description and tooltip, the zh map, the default (en and zh), a blank map value falling back to the default, and the predicate-false control. For the two menu renderings, the declared second line, the default, and the control.
  • ui/packages/components/src/__tests__/page-header-disabledReason-26619.test.tsx: inline, overflow, the zh bundle outranking the authored text, the default, and the control.
  • ui/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-26619.test.tsx and ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-26619.test.tsx: the declared string, the zh bundle, the default, and the control.
  • The -11811 / -11839 sibling suites, which pin the mechanics and the no-reason controls, pass unchanged.
Test readings (local, on the pushed head)
  • Head d96cc5bbb (f6a479a30 plus the i18n pin), branch restarted from main at aeb9cf0dd.
  • pnpm --filter @objectstack/spec test: Test Files 735 passed (735), Tests 22041 passed, 1 todo.
  • pnpm --filter @objectstack/lint test: Test Files 168 passed (168), Tests 7432 passed.
  • pnpm --filter @objectstack/spec check:liveness: OK. check:generated --fix: 2 of 14 stale on the first run (check:docs, check:liveness), regenerated and committed. node scripts/check-doc-authoring.mjs: exit 0.
  • pnpm --filter @object-ui/{types,i18n,react,components,plugin-detail,app-shell} type-check: all exit 0, after building @object-ui/app-shell^....
  • cd ui && pnpm exec vitest run packages/types/ packages/i18n/ packages/react/ packages/components/ packages/plugin-detail/ packages/app-shell/src/views/__tests__/: Test Files 1442 passed, 1 failed, 2 skipped; Tests 21406 passed, 1 failed. The one failure was a pin that moves with the new resolver: useObjectLabel-identity-5564.test.tsx counts useObjectLabel's members (27 → 28). It is updated in d96cc5bbb and passes (5/5).
  • ui/packages/core was not run locally. On main, its actionKeys.pin.test.ts reds on the new spec key (SPEC_ACTION_KEYS lacks disabledReason). The seat's hotfix fix(ui): the action key inventory lists disabledReason #26859 fixes that, and this branch takes it by a main merge once it lands. This PR does not add the row.
  • eslint over the 17 changed ui files: 0 errors (warnings are pre-existing no-explicit-any).

Acceptance notes

  • The action:* renderers honour the inline map, not the bundle. They render label as authored too. Giving them a bundle lookup would also need a key object they do not carry, and that is a wider change than this card. A registered action that reaches them through a surface that localizes (the record header, the section bar, the declared actions bar) already arrives with the bundle's sentence.
  • A blank reason falls back to the default on every surface, including a locale map whose value for the active language is whitespace. The spec refuses a blank string but not a blank map value (feat(spec): a greyed action says why — disabledReason beside disabled #26786's acceptance note).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; eslint over the 17 changed ui files: 0 errors.
  • 4. One issue, one branch restarted from main after feat(spec): a greyed action says why — disabledReason beside disabled #26786 merged, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm


Generated by Claude Code

The record header, the record section bar, the declared actions bar and the
four action:* renderers now show an action's declared disabledReason as the
hover title and accessible description (a menu item's second line) while its
declared disabled predicate is true. "Not available for this record" stays the
default when none, or a blank one, is declared.

useActionTextLocalizer resolves the key like the other action copy, through a
new actionDisabledReason resolver on useObjectLabel: the bundle's
_actions.ACTION.disabledReason first, then the authored I18nLabel collapsed to
the active language. UIActionSchema declares the key, derived from the spec.

The liveness row flips to live with the console readers as evidence, drops
authorWarn/authorHint, and its lint pin becomes a silence pin. The key's
describe() texts and the actions page no longer say nothing shows it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/spec/liveness/action.json, packages/spec/liveness/state-counts/action.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/liveness/action.json, packages/spec/liveness/state-counts/action.md) — pages documenting those are invisible to this run
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 147 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96e7a4335c0aa611a706664da6df339f672ec1ab → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cebef39ca4aff172805243a49ba19be7fb37d7c7 — the merge of head 4b5b87d21cb159072eabbd32bc58fc27c1827d6d into base 96e7a4335c0aa611a706664da6df339f672ec1ab, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cebef39ca4aff172805243a49ba19be7fb37d7c7 && git checkout cebef39ca4aff172805243a49ba19be7fb37d7c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96e7a4335c0aa611a706664da6df339f672ec1ab 4b5b87d21cb159072eabbd32bc58fc27c1827d6d && git checkout -B drift-repro 96e7a4335c0aa611a706664da6df339f672ec1ab && git merge --no-ff 4b5b87d21cb159072eabbd32bc58fc27c1827d6d

node scripts/docs-audit/affected-docs.mjs --json 96e7a4335c0aa611a706664da6df339f672ec1ab

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d96cc5bbb6cc9d0a35d2c1a4914b9d836a8b4699
Local-runs: none
Scope: packages/spec/src/system/translation.zod.ts, packages/spec/src/ui/action.zod.ts + packages/spec/liveness/action.json, packages/spec/liveness/state-counts/action.md, packages/lint/src/lint-liveness-properties.test.ts, packages/spec/liveness/object.json (externalSharingModel, the control row), packages/spec/scripts/liveness/check-liveness.mts (authorWarnOnLive, EVIDENCE_SCANNED_STATUSES, evidenceScope), content/docs/protocol/objectui/actions.mdx, the four regenerated content/docs/references/** pages, .changeset/26619-console-disabled-reason.md, ui/packages/components/src/renderers/layout/containers.tsx (headerActions, buttonable, disabledReasonFor, inline and overflow), ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx (actions memo, QuickActionButton), ui/packages/app-shell/src/views/DeclaredActionsBar.tsx (DeclaredActionButton, button and asMenuItem), ui/packages/components/src/renderers/action/disabled-reason.tsx (useDisabledReason, DisabledReasonTrigger, DisabledReasonMenuLabel) and the four action:* renderers, ui/packages/react/src/hooks/useActionTextLocalizer.ts, ui/packages/i18n/src/useObjectLabel.ts (actionSuffixes, resolve, actionDisabledReason), ui/packages/i18n/src/pickLocalized.ts, ui/packages/types/src/ui-action.ts, ui/packages/core/src/actions/tests/actionKeys.pin.test.ts (main's own red), the five new test suites and the moved identity pin

Ruling executed: 6442737825 on #26619, letter A, second half (the console readers). Inputs read: the card body and all seven comments (triage 6440296436, brief 6441887048, ruling 6442737825, claim 6444476085, os-dev-report 6446453447 for PR 1, landing note 6449918956, os-dev-report 6451252645 for this PR); PR #26786's two contract records (6446707981, 6447429917) and its ACCEPT (6448592048) as background for what this PR owes; the PR #26869 body, its 28-file list, the net diff origin/main...pr26869 (merge-base aeb9cf0dd, 28 files, +824/-60, matching the file list exactly); and the check-runs on d96cc5bbb at 19:15Z and 19:19Z.

Head provenance. d96cc5bbb is two commits on a branch restarted from main at aeb9cf0dd, which carries the #26786 merge 13dc413f as an ancestor, so the spec half this PR reads is the merged one. f6a479a30 is the feature; d96cc5bbb moves the useObjectLabel identity pin 27 to 28. origin/main is at 8eef9a8db, three commits past the branch point, none touching a PR path.

Check-runs on the head at review time (19:19:28Z, two REST reads): 58 runs, 19 success, 5 skipped, 34 in_progress, 0 failure. Rendered and green: Spec property liveness (the check:liveness gate that reads the flipped row), Lint · docs & code guards (the check:doc-authoring Rule 3 walk over the describe strings), Lint · ESLint, Lint · PM dispatch-gates, Lint · repo & tooling gates, Lint · CI, release & engine gates, Check Changeset, Governed Surface Queue Guard, PR-body guards, Build UI Deps, Check Documentation Links, Flag docs affected by code changes, Plan Test Core shards, Dogfood Regression Gate. Still running: Test Core 1 to 8 (the lint silence pin and the spec suites live there), Test UI 1 to 16 (the five new suites and the identity pin), Type Check UI, Type Check x4, Build Core, Build Docs, Build & E2E UI, Lint UI, Lint · scoped · tooling self-tests. One Test UI shard is expected to go red on ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts ("missing: disabledReason"): that pin reds on main itself since #26786 landed, the fix is the seat's #26859, which is still OPEN at review time, and this PR does not touch actionKeys.ts. It is main's red, not this PR's, and is not counted here; see ③ for what the queue needs. This record judges the tree; the queue guard holds the PR until every check is green.

① Derived judgments

What PR 1 promised this PR would do, judged one by one.

  1. Liveness row disabledReason flipped to live with real readers as evidence: RIGHT. packages/spec/liveness/action.json now reads status: live, verifiedAt: 2026-10-27, evidenceScope: in-repo, and an evidence string naming seven pointers. Each was read in the diff and resolves to a hunk that reads the key: containers.tsx#disabledReasonFor, record-quick-actions.tsx#QuickActionButton, DeclaredActionsBar.tsx#DeclaredActionButton, disabled-reason.tsx#useDisabledReason, useActionTextLocalizer.ts#useActionTextLocalizer, useObjectLabel.ts#useObjectLabel (actionDisabledReason), plus the two framework pointers carried over from the planned row (i18n-resolver.ts#translateAction, i18n-extract.ts#collectExpectedEntries). evidenceScope: in-repo is the gate's optional scope marker (check-liveness.mts, the EVIDENCE SCOPE block): it records that the look was this repo alone, which is the truth for a console reader that lives under ui/. live is in EVIDENCE_SCANNED_STATUSES, so the gate resolves every pointer, and Spec property liveness is green on this head. liveness/state-counts/action.md was regenerated: live 47 to 48, planned 1 to 0, classified 49 unchanged.
  2. authorWarn and authorHint dropped: RIGHT, and required. check-liveness.mts#scanAuthorWarnRows pushes a live row carrying authorWarn onto report.authorWarnOnLive, which is a red condition. The row carries neither key. The note keeps the card id and the gate's own number; a note is maintainer prose the author-side lint never shows, so no tracker id reaches an author.
  3. Lint block turned into a silence pin, non-vacuous: RIGHT. lint-liveness-properties.test.ts replaces the two warning-era cases with one. It authors disabledReason on an action AND externalSharingModel: 'read' on an object in the SAME lintLivenessProperties call; externalSharingModel is a dead row in the shipped liveness/object.json (read: status: dead, tombstoned under ADR-0049), so the call must draw LIVENESS_DEAD_PROPERTY for it, and does, while ruleOf(findings, 'disabledReason') is undefined. A lint that stopped loading ledgers would answer [] for both and fail the first assertion, which is what makes the silence a reading. The block then reads the shipped action.json row through the file's existing shippedLedgerDir() helper and pins status === 'live', no authorWarn, no authorHint, and that evidence contains containers.tsx#disabledReasonFor. The precedent (outcomeMessages) was followed. The test is in @objectstack/lint, so its verdict is Test Core's, still running.
  4. .describe() texts and actions.mdx back in the present tense, no tracker id in a describe string: RIGHT. action.zod.ts line 1586 and translation.zod.ts line 218 now say what the console does ("Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while disabled evaluates TRUE"; "Translated reason a greyed action shows (hover title and accessible description) while its disabled predicate is TRUE"). A grep for a # followed by three to five digits over both strings returns zero; the card id stays in the adjacent // comment and docblock, where Rule 3 permits it. content/docs/protocol/objectui/actions.mdx line 339 drops "The console does not show it yet" and the os validate warning sentence for "The console shows it while the predicate is true, as the button's hover title and its accessible description (a menu item shows it under its label). Without it the reason is the console's default". The four regenerated reference pages carry the new strings byte for byte: ui/action.mdx, data/object.mdx, kernel/metadata-plugin.mdx the action text, system/translation.mdx the translation text at all five addresses. Lint · docs & code guards is green on this head.

The readers, each one asked whether it renders the declared reason.

  1. containers.tsx#disabledReasonFor (record header, inline and overflow): RENDERS IT. The function reads action.disabledReason as a string, trims, and falls back to tt('actions.notAvailableForRecord', 'Not available for this record'). It is called from renderButton (inline) and from the overflowActions.map (the ⋯ menu). Both lists derive from headerActions (buttonable = headerActions.filter(...), ranked from buttonable, inlineActions and overflowActions from ranked), and headerActions is the memo that runs every raw action through localizeActionTexts(ctx?.objectName, a, ...). So the string disabledReasonFor reads is already the bundle's sentence, else the authored I18nLabel collapsed to the active language, which is why a plain typeof === 'string' check is sound there. Inline: the pre-existing objectui#11811 wrapper (Tooltip on the focusable span, aria-describedby on button and span to an sr-only copy). Overflow: DropdownMenuItem with aria-labelledby to the label span and aria-describedby to a visible second line. Default fallback: the generic key. Bundle translation: through the memo. Tested in page-header-disabledReason-26619.test.tsx (inline tooltip and description, overflow second line visible and as description, zh bundle outranking the authored English with the English asserted absent, default, predicate-false control asserting no aria-describedby and no declared text).
  2. record-quick-actions.tsx#QuickActionButton (record section bar): RENDERS IT. The actions array is built by localizeActionTexts(objectName || undefined, a, ...) before any button renders; QuickActionButton reads action.disabledReason as a string, trims, falls back to the generic key, and the existing wrapper serves tooltip plus sr-only description. Tested in record-quick-actions.disabledReason-26619.test.tsx (declared string as description and tooltip, zh bundle outranking, default, control).
  3. DeclaredActionsBar.tsx#DeclaredActionButton (declared actions bar, button and override menu item): RENDERS IT. localizedTexts = localizeActionTexts(objectName, action) is now resolved once and both declaredLabel and declaredReason come off it, so the reason and the label can never come from different bundle reads (the objectui#4265 discipline). disabledReason is the trimmed declared string when the DECLARED predicate holds, else the generic key, else undefined. The asMenuItem branch shows it as the visible second line with aria-labelledby and aria-describedby; the button branch wraps in the tooltip trigger with the sr-only copy. Tested in DeclaredActionsBar.disabledReason-26619.test.tsx (declared string as description and tooltip, accessible name stays Archive, zh bundle outranking, default, control).
  4. disabled-reason.tsx#useDisabledReason (the four action:* renderers): RENDERS IT, inline map honoured, bundle not consulted, and that is consistent with label on the same components. The hook gains declaredReason?: unknown, collapses it with pickLocalized(declaredReason, language) (exact tag, base, regional twin, default, en, first value; own string properties only), trims, and falls back to the generic key. action-button.tsx, action-icon.tsx, action-group.tsx (both the inline member and the dropdown item) and action-menu.tsx pass schema.disabledReason or action.disabledReason. Rendering is the existing DisabledReasonTrigger (tooltip plus sr-only description, describedByWithReason keeping an authored aria-describedby) and DisabledReasonMenuLabel with menuItemReasonAria (second line, aria-labelledby and aria-describedby). Weighed against label: these four render {schema.label}, {action.label} and {action.label || action.name} verbatim, with no useObjectLabel and no pickLocalized on the label at all. The reason therefore gets one step MORE localisation than the label on the same component (a map collapses instead of rendering as an object), and no less bundle treatment. A bundle lookup there would need a key object the SDUI node does not carry, as the dev says; a registered action reaches these renderers through a localising surface already carrying the bundle's sentence. Consistent, documented in the module header, and the dev's out-of-scope note is answered. Tested in action-disabled-reason-26619.test.tsx, three button sites by five cases and two menu sites by three cases: declared string as description AND tooltip with the default asserted absent from the tooltip, { en, 'zh-CN' } map collapsed to the zh sentence under a zh provider, default in en and in zh (对此记录不可用), { en: ' ' } falling back to the default, predicate-false control; menu: declared second line visible and as description with the accessible name staying Archive, default, control.
  5. Translation through the bundle (useObjectLabel.actionDisabledReason and useActionTextLocalizer): RIGHT. actionDisabledReason(objectName, actionName, fallback?) is the actionConfirm pattern verbatim: resolve(actionSuffixes(objectName, actionName, 'disabledReason'), fallback ?? '') then resolved || undefined. actionSuffixes yields objects.{o}._actions.{a}.disabledReason (with the namespace-stripped twin) for a bound action and globalActions.{a}.disabledReason for an object-less one, the same two addresses translateAction and os i18n extract use. In the localizer the reason is collapsed with pickLocalized whenever declared (!== undefined), returned collapsed on the nameless path, and on the named path replaced by actionDisabledReason(keyObject, name, collapsed.disabledReason). Declared-only holds: an action with no key never has disabledReason added, even when the bundle holds an entry (pinned). Blank handling: a map whose active-language value is '' collapses to '', the resolver answers undefined, every reader's typeof === 'string' check misses and the default shows; a whitespace value passes through as whitespace and every reader's trim() !== '' catches it. The acceptance note "a blank reason falls back to the default on every surface" is true in the tree. useObjectLabel has one return path (the memo holds with or without a provider; SAFE_FIELD_LABEL_FALLBACK is reached only by useSafeFieldLabel, which the localizer does not use), so the new member is present wherever the localizer destructures it. Tested in useActionTextLocalizer.disabledReason-26619.test.tsx (6): authored string, bundle outranks authored at the object address, map collapsed to zh, globalActions address, no key added when undeclared, nameless map collapsed.
  6. UIActionSchema.disabledReason truly derived from the spec: RIGHT. ui/packages/types/src/ui-action.ts declares disabledReason?: SpecAction['disabledReason'] where SpecAction is Action imported from @objectstack/spec/ui (@objectstack/spec: workspace:^), beside disabled?: SpecAction['disabled']. The type is the spec's I18nLabel union by reference, not a re-spelling.
  7. Hover title and accessible description both served, as the card asked: YES. On every button surface the reason is served twice: as a tooltip that opens on hover and on keyboard focus of the wrapper span (a native title cannot open on a natively disabled button, which the objectui#11811 and feat(spec,plugin-email,service-messaging,plugin-auth): widen SendEmailInput with optional organizationId, threaded from org-holding producers #11839 mechanics ruled and documented), and as a persistent aria-describedby to an sr-only copy that a screen reader hears with the tooltip closed. On a menu item the reason is a visible second line that is the item's description while the name stays the label, because a disabled item is unreachable by hover and by roving focus. The new suites assert both channels per site (findByRole('tooltip') after user.hover, and aria-describedby resolving to the declared text).
  8. Tests non-vacuous: YES. Every suite has a declared-reason case asserting text (Finish the checklist before archiving.) the pre-change readers could never produce, a default case, and a predicate-false control that asserts the absence of aria-describedby and of the declared text; the bundle cases assert the zh sentence present and the authored English absent. The identity pin useObjectLabel-identity-5564 moves 27 to 28 with the ledger comment naming actionDisabledReason, which is one new member and the honest arithmetic. The -11811 and -11839 sibling suites are byte-identical to main and still pin the generic text for actions that declare none, so they remain a control. One nit: the action:* case titled "a map with no usable entry for the language falls back to the default" authors { en: ' ' } under an en provider, which is a BLANK entry for the language, not a missing one (a map with no entry at all for the language would fall through pickLocalized's en and first-value limbs and show whatever it finds). The assertion is real; the title overstates it. Should-fix, the dev's call.
  9. Accept-set and public-surface changes the diff implies. Spec accept-set: none; actionDisabledReason() and actionTranslationSchema are untouched, only their .describe() strings move. Public surface in @object-ui/*: UIActionSchema.disabledReason (new optional member, @object-ui/types); useObjectLabel() gains actionDisabledReason (@object-ui/i18n); useActionTextLocalizer now emits disabledReason on its returned copy when declared (@object-ui/react). useDisabledReason's second parameter is module-private: nothing in renderers/action/ is re-exported from the components entry (header says so; the entry files carry no reference). All additive. Judged under ② for the level.

② Semver level

Changeset .changeset/26619-console-disabled-reason.md at the ROOT .changeset/: @objectstack/spec, @object-ui/types, @object-ui/i18n, @object-ui/react, @object-ui/components, @object-ui/plugin-detail, @object-ui/app-shell, all patch; Clause-②: no; Metadata-protocol: none. The PR body carries the same two lines.

  • Root placement: RIGHT. ui/AGENTS.md (the changeset bullet) says changesets are written at the repo root and that ui/.changeset/ holds only pre-merge leftovers the root config does not read; the root .changeset/config.json lists every one of the seven packages in its single fixed group, and privatePackages.version: true covers @object-ui/app-shell (private: true). Check Changeset is green.
  • Clause-②: no: RIGHT. No published schema's accept set moves; the spec diff is two describe strings, the ledger row and its state count.
  • Metadata-protocol: none: RIGHT. Nothing an author can write changes. The key, its shape, its blank refusal and its translation slot were added by feat(spec): a greyed action says why — disabledReason beside disabled #26786; this PR makes the console honour them. A reader catching up to a declared contract is not a changed meaning or default of the key.
  • patch: RIGHT under the repo's rule, with the conventional reading named for the seat. AGENTS.md Post-Task step 3 binds the level to the kind alone: a kind takes at least minor, none takes patch, and "a non-protocol change, widening or breaking, ships patch". Under plain semver the additions in ①.13 (a new member on an exported hook's return, a new optional field on an exported interface, a new key on a localizer's output) are a feature and would read minor. The repo rule is explicit and the seven packages sit in one fixed group with every @objectstack/* package, so the group's next number is the only thing the word decides. Not a must-fix; the seat may lift it to minor if it wants the CHANGELOG to say feature, and nothing in the tree would need to move.
  • @objectstack/lint without a changeset: fine. The diff moves one test file there and no source; Check Changeset agrees.
  • Body matches what the diff publishes: YES on substance; one stale count. Every "What changes" sentence was re-read against the tree and holds: the four surfaces, the two bundle addresses, the declared-only rule, the inline-map rule on the action:* renderers, the UIActionSchema derivation, the ledger flip with evidenceScope: in-repo, the dropped authorWarn/authorHint, the regenerated state count, the silence pin reading the shipped row, the two describe texts, the four regenerated reference pages, and the test inventory (6; 3x5 plus 2x3; 5; 4; 4). The stale sentence is a count that moved with the second commit: in "Test readings" the line eslint over the 16 changed ui files: 0 errors (warnings are pre-existing \no-explicit-any`).and in Human Minimum 3 the lineNo formatter ran; eslint over the 16 changed ui files: 0 errors.Atd96cc5bthe diff touches 17 files underui/(theuseObjectLabel-identity-5564pin joined in the second commit). Replacement for both:eslint over the 17 changed ui files: 0 errorswith the rest of each sentence unchanged.Lint · ESLint` is green on the head, so the claim is true in substance; only the number is stale. Should-fix.

③ Boundary flags

④ Design judgment

packages/spec/src/ui/action.zod.ts (the disabledReason describe text) — holds. The schema is untouched; the text now claims three render surfaces (hover title, accessible description, a menu item's second line), one condition (while disabled evaluates TRUE), the two bundle addresses, the not-a-gate clause, the default sentence and the blank refusal. Every claim was checked against the readers in this diff: the three surfaces are ①.5 to ①.8 and ①.11; the condition is the hasDeclaredVisibilityGate(...) && isDisabledPred guard every caller passes (a button greyed by the host, by its own run, or by the legacy enabled leg gives no reason, pinned by the untouched sibling suites); the addresses are actionSuffixes; the default is the shared key; the refusal is NON_BLANK_STRING from #26786. The string carries no tracker id and projects into three reference pages byte for byte. Tried to break: a describe text that overstates the window (the PR 1 should-fix) is now closed by the tree it describes; a map value that is blank still shows the default, which the text's "A blank reason is refused" does not contradict since the refusal clause is about the string arm (the #26786 record settled that reading in the docs sentence that follows it). No must-fix.

packages/spec/src/system/translation.zod.ts (the bundle leaf's describe text) — holds. The leaf type, factory and both addresses are untouched; the text names hover title and accessible description, which is what the bundle's sentence reaches on the three localising surfaces (the menu second line on DeclaredActionsBar's override item and the header overflow is a third place the same sentence lands; the shorter text omits it, harmless). No tracker id; projects into system/translation.mdx at all five addresses byte for byte. No must-fix.

packages/spec/liveness/action.json row disabledReason — holds, and is the shape the precedent prescribes for the flip. live, dated, evidenceScope: in-repo, seven resolvable pointers each read in this diff, no authorWarn, no authorHint, a note that says which surfaces use the bundle and which the inline map. The gate is green on it and the silence pin reads it.

Must-fix: none.

Should-fix (non-blocking): (1) the two PR-body sentences counting "16 changed ui files" read 17 at this head (② gives the replacement); (2) the action:* test title "a map with no usable entry for the language falls back to the default" describes a blank entry, not a missing one; (3) seat-side: land #26859 ahead of this PR in the queue, and correct the card's landing note, which names #26820 where the skills row is #26790.

Implemented-by: claude/project-thread-yxepsj
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-27T19:56Z

Head 4b5b87d21cb159072eabbd32bc58fc27c1827d6d. Card #26619 (ruling 6442737825, letter A), claim 6444476085, dev report 6451252645. This PR is the console half (Fixes #26619). The spec half, #26786, landed at 13dc413f.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants