diff --git a/.changeset/26790-actions-disabled-reason-skills.md b/.changeset/26790-actions-disabled-reason-skills.md new file mode 100644 index 00000000000..3a122071494 --- /dev/null +++ b/.changeset/26790-actions-disabled-reason-skills.md @@ -0,0 +1,12 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): the actions rule, its eval and the i18n skill teach an action's `disabledReason` + +Clause-②: no +Metadata-protocol: none + +- **Before.** `ActionSchema.disabledReason` (the display-only sentence a greyed action shows beside its `disabled` predicate) was on the protocol, but no published skill named it. An agent authoring an action from the skill left the console's default "Not available for this record" in place, or wrote the reason into `description`. +- **Now.** `objectstack-ui/rules/actions.md` has one bullet beside `disabled`: an I18nLabel, display-only, never a predicate or a gate; omitted keeps the console's default, a blank one is refused, and `disabledMessage` / `disabledTooltip` / `disabledHint` / `disabledText` are refused naming the key; the ReassignLead example carries it beside `disabled`. Eval 9 in `evals/views-apps-actions-pages.json` expects `disabledReason` beside a `disabled` predicate and forbids the reason in `description`; cases 5 and 8 are reworded so neither spells its own `must_not_contain` string. The `objectstack-i18n` group table lists `disabledReason` under `_actions.{action_name}` (and so under `globalActions.{action_name}`) beside `confirmText` / `successMessage`. +- No schema, key or behaviour changes. diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 60c0d479b7f..71cc7ebc724 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -769,7 +769,17 @@ export const CEILINGS = new Map([ // prose above it), then the row's 7 tokens of headroom absorb 7; the ceiling moves by the // remaining 43 and is pinned AT the measurement — zero headroom. Maintainer ruling 2026-10-27 (Warren, decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」, // option A tapped at 01:21Z), verbatim and untranslated: 「A:先削重复,余额按实测提高」. - ['skills/objectstack-ui/rules/actions.md', 2151], + // 2151 -> 2259: the `disabledReason` teaching beside `disabled` — one bullet (an I18nLabel, + // display-only, never a predicate; omitted = the console's default reason, blank refused, the + // four near-miss spellings refused naming it, the reason never in `description`) and the key on + // the `disabled` line of the ReassignLead example. PAID DOWN FIRST by 122 bytes of true + // duplicate in this file: two code comments in that example restating the bullets above it + // (`// Greys out (stays visible) …` — the `disabled` bullet; `// success toast offers Undo; + // Ctrl+Z works too` — the `undoable` bullet). The file is 8602 -> 9035 bytes -> 2259 tokens, + // zero headroom to absorb any of it, ceiling +108, pinned AT the measurement. Same ruling as + // the line above (2026-10-27, tap A: 「A:先削重复,余额按实测提高」). + // ceil($(wc -c < skills/objectstack-ui/rules/actions.md) / 4) == ceil(9035 / 4) + ['skills/objectstack-ui/rules/actions.md', 2259], // 6090 -> 6252 (#19715, card item 4): a 5-line table of contents at the top, // anchors to the file's existing headings, nothing else changed. +162 tokens, // zero headroom to absorb any of it, ceiling +162. Maintainer ruling @@ -961,7 +971,14 @@ export const CEILINGS = new Map([ // + `onSuccess` pair — +284 tokens in the file's shape; fixtures, nothing to delete, the // whole addition raised and pinned AT the measurement. Maintainer ruling 2026-10-27 (Warren, decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」, // option A tapped at 01:21Z), verbatim and untranslated: 「A:先削重复,余额按实测提高」. - ['skills/objectstack-ui/evals/views-apps-actions-pages.json', 1789], + // 1789 -> 2009: case 9, the `disabledReason` beside a `disabled` predicate (the reason never in + // `description`, the near-miss spellings refused) — +882 bytes; the file's cases share no + // sentence, so there was no duplicate to cut (0 bytes); cases 5 and 8 reworded so neither + // spells its own `must_not_contain` string (-2 bytes). 7154 -> 8034 bytes -> 2009 tokens, + // ceiling +220, pinned AT the measurement. Same ruling as the line above (2026-10-27, tap A: + // 「A:先削重复,余额按实测提高」). + // ceil($(wc -c < skills/objectstack-ui/evals/views-apps-actions-pages.json) / 4) == ceil(8034 / 4) + ['skills/objectstack-ui/evals/views-apps-actions-pages.json', 2009], ['skills/objectstack-upgrade/evals/protocol-major-upgrade.json', 1033], // the remaining skills' eval notes diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index aef395b5384..25c1db25b29 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -161,7 +161,7 @@ All translatable content for a single object is aggregated under | `label` / `pluralLabel` / `description` | Object-level text (every key optional) | | `fields.{field_name}` | `label`, `help`, `placeholder`, `options` (option value → label), `inlineTitle` (the heading of the inline child grid a `master_detail` field draws on its parent's form — the field lives on the child object, so the key is the child's; untranslated it keeps its authored text, never the child's plural label) per field | | `_views.{view_name}` | `label`, `description`, `title` (a form view's own heading — only a form that authors one is translated; a list view has none), `emptyState.title` / `emptyState.message`, `bulkActions.{def_name}` (`label`, `confirmText`, `confirmLabel`, `params`) | -| `_actions.{action_name}` | `label`, `description`, `confirmText`, `successMessage`, `params.{param_name}` (and `.options.{value}` beneath it — an option's stored `value` in that param's inline `options`), `outcomeMessages.{outcome}` (an outcome the action's own `outcomeMessages` declares), `resultDialog.title` / `.description` / `.acknowledge` (only under an action that declares a `resultDialog`), `resultDialog.fields.{path}` (the literal `path` of a field in `resultDialog.fields[]`, dots included — `client.secret` is one key); the same keys hold under `globalActions.{action_name}` | +| `_actions.{action_name}` | `label`, `description`, `confirmText`, `successMessage`, `disabledReason` (the sentence a greyed action shows; absent keeps the console's default), `params.{param_name}` (and `.options.{value}` beneath it — an option's stored `value` in that param's inline `options`), `outcomeMessages.{outcome}` (an outcome the action's own `outcomeMessages` declares), `resultDialog.title` / `.description` / `.acknowledge` (only under an action that declares a `resultDialog`), `resultDialog.fields.{path}` (the literal `path` of a field in `resultDialog.fields[]`, dots included — `client.secret` is one key); the same keys hold under `globalActions.{action_name}` | | `_sections.{section_name}` | Form section `label`, `description`; `fields.{field_name}` — the `label`, `placeholder`, `helpText` an entry overrides for that form alone (`helpText` here, `help` under `fields.{field_name}` above: each spells what its source does) | | `_tabs.{tab_name}` | Filter-preset tab `label` (`ViewTabSchema.name`) | | `_validations.{rule_name}` | `message` only — the sentence a rejected write returns (replaces the retired `validationMessages`) | diff --git a/skills/objectstack-ui/evals/views-apps-actions-pages.json b/skills/objectstack-ui/evals/views-apps-actions-pages.json index cb958f4a977..658e7e480e6 100644 --- a/skills/objectstack-ui/evals/views-apps-actions-pages.json +++ b/skills/objectstack-ui/evals/views-apps-actions-pages.json @@ -44,7 +44,7 @@ { "id": 5, "prompt": "Sales reps need a locked-down task workbench: only open tasks, sorted by due date, grid only, no switching to other views; plus a report of opportunity amounts by stage.", - "expected_output": "Capability narrowing is the signal for an interface page: `definePage({ type: 'list', object: 'task', interfaceConfig: { source: 'task', columns: [...], sort: [...], filterBy: [...], userFilters: { element: 'dropdown', ... }, appearance: { allowedVisualizations: ['grid'] }, userActions: {...} } })` — the page IS the view definition (no deprecated `sourceView`). The report is `type: 'summary'` selecting `rows` / `values` from a declared `dataset`; there is no `chart` report type.", + "expected_output": "Capability narrowing is the signal for an interface page: `definePage({ type: 'list', object: 'task', interfaceConfig: { source: 'task', columns: [...], sort: [...], filterBy: [...], userFilters: { element: 'dropdown', ... }, appearance: { allowedVisualizations: ['grid'] }, userActions: {...} } })` — the page IS the view definition (no deprecated source view). The report is `type: 'summary'` selecting `rows` / `values` from a declared `dataset`; there is no `chart` report type.", "files": [], "assertions": { "must_contain": ["definePage", "interfaceConfig", "columns", "allowedVisualizations", "summary", "dataset"], @@ -74,12 +74,22 @@ { "id": 8, "prompt": "Make the Reassign Lead toast read 'Lead NAME reassigned'; after Convert's lead_conversion flow completes, open the opportunity it created.", - "expected_output": "`successMessage: 'Lead {{ record.name }} reassigned'` (`${record.name}` fails `os validate`); Convert gets `onSuccess: { navigate }` ending `${result.output.opportunity_id}`, an `isOutput` flow variable.", + "expected_output": "`successMessage: 'Lead {{ record.name }} reassigned'` (a `${…}` hole fails `os validate`); Convert gets `onSuccess: { navigate }` ending `${result.output.opportunity_id}`, an `isOutput` flow variable.", "files": [], "assertions": { "must_contain": ["{{", "onSuccess", "result.output"], "must_not_contain": ["${record.name}"] } + }, + { + "id": 9, + "prompt": "Approve on `contract` should stay visible but greyed until `legal_review_status` is `approved`, and the rep should see why it is greyed.", + "expected_output": "`disabled: P`record.legal_review_status != \"approved\"`` with `disabledReason: 'An approved legal review is required before approval.'` beside it — an I18nLabel the console shows as the greyed button's hover title; display-only, never a predicate, not a gate. The reason is not written into `description`; omitting the key keeps the default \"Not available for this record\", and a blank reason or a `*Message` / `*Tooltip` / `*Hint` spelling of the key is refused.", + "files": [], + "assertions": { + "must_contain": ["disabled:", "disabledReason"], + "must_not_contain": ["description:", "disabledMessage", "disabledTooltip", "visible:"] + } } ] } diff --git a/skills/objectstack-ui/rules/actions.md b/skills/objectstack-ui/rules/actions.md index fa8ae5de9b4..df0b0814460 100644 --- a/skills/objectstack-ui/rules/actions.md +++ b/skills/objectstack-ui/rules/actions.md @@ -17,6 +17,7 @@ - `visible` — CEL predicate (prefer the `P\`...\`` tagged template); when false the action is **hidden**. - `disabled` — `boolean` **or** a CEL predicate; when true the action **shows but greys out**. Use this (not `visible`) when the action should stay discoverable but locked in the current state. +- `disabledReason` — an I18nLabel (a plain sentence or an inline locale map) a greyed action shows as its hover title and accessible description. Display-only: never a predicate and never a gate (the hook or permission still refuses). Omit it to keep the console's default "Not available for this record"; a blank one is refused, and `disabledMessage` / `disabledTooltip` / `disabledHint` / `disabledText` are refused naming this key. The reason goes here, not in `description`. - `confirmText` — set for any destructive or irreversible operation. - `successMessage` / `errorMessage` — toast copy on success / failure. Always set `successMessage` for non-obvious outcomes; without it the UI shows a generic "Action completed" toast. - Copy holes are `{{ }}`: `confirmText` reads `record`, `successMessage` `record` and `result` (the handler's answer); `errorMessage` takes none. A `bulkActionDefs` entry's `confirmText` reads `{{ count }}` and its params by bare name (`{{ tier }}`). `${record.name}`, `{name}`, logic in a hole and a blank sentence fail `os validate`. @@ -39,10 +40,10 @@ export const ReassignLeadAction = defineAction({ type: 'api', target: 'lead', locations: ['record_header', 'list_item'], - // Greys out (stays visible) once the lead is converted: disabled: P`record.status == "converted"`, + disabledReason: 'A converted lead is reassigned from its opportunity.', params: [{ field: 'assigned_to', required: true }], - undoable: true, // success toast offers Undo; Ctrl+Z works too + undoable: true, successMessage: 'Lead {{ record.name }} reassigned.', errorMessage: "Couldn't reassign this lead — try again.", });