Skip to content

Commit 3b45d4a

Browse files
docs(skills): objectstack-ui and objectstack-i18n teach pressed, the five {{ }} copy slots, submitText, the thank-you keys, kanban.lanes and report seriesBy (#28488)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoX9GDX2cb55BEVTSE1eYheJ)_ Fixes #28243 Fixes #28464 Fixes #28190 Fixes #28215 Fixes #28212 Fixes #28207 Clause-②: no Metadata-protocol: none domain:skills#1 · os-tesla · session_01L9qibz4G9FrLR4TRExx6gn ## 维护者速读 - 改了什么:两个对外发布的技能包补教六个平台已经在画的键——动作的 `pressed`(本人已操作的切换态)、动作文案五个 `{{ }}` 槽位(`description`、参数 `helpText`、`outcomeMessages`)、参数对话框按钮文字 `submitText` 及其翻译槽位、表单感谢页的翻译键、看板 `kanban.lanes`、报表图表 `seriesBy`;附一条 `submitText` 的 eval。 - 为什么改:这些键在 spec 和控制台都已落地,但技能文本没教,AI 作者照技能写出的元数据要么用了 `${…}` 被 `os validate` 拒收,要么绕路(用 `matrix` 代替 `seriesBy`,用过滤器想去掉空泳道),要么根本不知道可以改按钮文字、做切换态。 - 风险与回滚:纯文档,不改 schema、键或行为;三个零余量文件的新增全部以同文件删减支付,token 天花板一行不动。回滚 = 撤这个 PR。 - 先看:`skills/objectstack-ui/rules/actions.md`(六条 bullet 和 `ctx` 一节的压缩是风险 hunk);`skills/objectstack-ui/rules/dashboards.md` 的报表示例与 `seriesBy` 注;`skills/objectstack-i18n/SKILL.md` 的 `_views` / `_actions` 两行。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs`,预期四个被改文件全绿且 headroom 为 4 / 21 / 2 / 687;`git grep -n 'seriesBy\|lanes:\|submitText\|pressed' skills/objectstack-ui skills/objectstack-i18n` 每个键至少一处命中。 - 需要你做的:受管面(`skills/**`,Tier H)——席位做 Fable 契约复审并写席位核对后,由 os-zhuang 审批落地;dev 不翻 ready、不请求审阅。 ## What changes Six teaching edits, one commit per card (plus a one-line fix-up that swaps a reserved word out of an example), in the files the fold order names. Nothing under `skills/*/references/_index.md`. | Card | Where | What the skill now says | Read against (`main` 82b29fe) | |:--|:--|:--|:--| | #28243 | `skills/objectstack-ui/rules/actions.md`, a bullet under *Visibility, Disable & Feedback* | `pressed` is a CEL predicate over the same scope as `visible`; while TRUE the record header draws the button pressed (`aria-pressed`); display only, never a gate; a boolean literal is refused; `active` / `activeWhen` / `pressedWhen` rename onto it; a per-reader fact is a block declared under `attachedOnRead`, filled per caller, read as `has(record.viewer) && record.viewer.vote == 'helpful'` | `packages/spec/src/ui/action.zod.ts:1659-1683` (docblock + describe), `:1013` (renames); `packages/spec/src/ui/action-pressed.test.ts:60` (boolean refused); `packages/spec/src/data/object.zod.ts:2441-2470`, `:3201` (`attachedOnRead`); renderer `ui/packages/components/src/renderers/layout/containers.tsx:2414-2428`, `:2468` (`page:header`) | | #28464 | same file, the "Copy holes are `{{ }}`" bullet | `confirmText`, `description` and a param's `helpText` read `record`; `successMessage` and each `outcomeMessages` entry read `result` and `record`; `errorMessage` takes none; one example per new slot; `${record.name}`, `{name}`, logic in a hole and a blank sentence fail `os validate` in all of them | `packages/spec/src/ui/action-copy-template.ts:19-45` (roots per key), `:56-70` (the doors refuse), `:143-145` (the reference examples); `packages/spec/src/ui/action.zod.ts:179-182`, `:396`, `:1117`, `:1532` | | #28190 | same file (`confirmText` and `submitText` bullets, `ReassignLeadAction` example); `skills/objectstack-ui/evals/views-apps-actions-pages.json` (eval 11); `skills/objectstack-i18n/SKILL.md` (`_actions` row) | `submitText` is the verb on the param dialog's submit button in place of "Confirm", an I18nLabel, refused blank and refused without a non-empty `params`; `submitLabel` / `confirmLabel` rename onto it; `confirmText` is the param-less action's confirm dialog, refused beside `params`; translation slot `objects.OBJECT._actions.ACTION.submitText` | `packages/spec/src/ui/action.zod.ts:936-969` (docblock, blank refusal), `:1010` (renames), `:1436` (describe), `:2248-2257` (`confirmText` beside `params`), `:2269` (`submitText` without `params`); `packages/spec/src/system/translation.zod.ts:220-223`, `:513`, `:1042`; renderer `ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:345-364`, `ui/packages/app-shell/src/views/ActionParamDialog.submitText-28158.test.tsx:10-11` | | #28215 | `skills/objectstack-i18n/SKILL.md`, the `_views.{view_name}` row | `submitBehavior.title` / `submitBehavior.message` translate the thank-you panel of a form whose `submitBehavior` is `kind: 'thank-you'`; only authored copy is translated; the other kinds carry none | `packages/spec/src/system/translation.zod.ts:271-297` (the shape), `:335`, `:386-402` (the `_views` member); renderer `ui/packages/plugin-form/src/ObjectForm.tsx:637`, `:1328-1329` | | #28212 | `skills/objectstack-ui/rules/list-views.md`, the kanban example and the note under it | `lanes` lists the stored option values to draw, in that order; a choice of lanes, not a record filter (an unlisted value lands in `Uncategorized`; filter the view to keep records off the board); omit it for one lane per option; a non-option or repeated value fails `os validate` | `packages/spec/src/ui/view.zod.ts:2320-2365` (`KanbanConfigSchema.lanes`); `packages/lint/src/validate-list-view-field-refs.ts:370-392`, `:1083-1140` (the two refusals); renderer `ui/packages/core/src/utils/kanban-declared-lanes.ts:10-40` | | #28207 | `skills/objectstack-ui/rules/dashboards.md`, the report example and the note under it | `seriesBy` splits the one `yAxis` measure into a series per value of a second dataset dimension, not a `matrix` workaround; it must name a declared dimension other than `xAxis` (publish refuses an unknown name); refused on `pie` / `donut` / `funnel` / `treemap` / `sankey`; with it, `series[].name` names one of its values | `packages/spec/src/ui/report.zod.ts:33-57` (key + describe), `:147-203` (`checkReportChartSeriesSplit`, the two arms from PR #28439); `packages/lint/src/validate-chart-bindings.ts:589-604` (undeclared dimension); renderer `ui/packages/plugin-report/src/DatasetReportRenderer.tsx:1375-1395` | **Premise re-checked before each commit:** `git grep` on `main` 82b29fe finds none of `pressed`, `seriesBy`, `lanes` (kanban), `submitText` (action), `description` / `helpText` / `outcomeMessages` as `{{ }}` slots, or `submitBehavior.title` under `skills/` — the only prior mentions are the generated `references/react-blocks.md` rows for the `object-form` block and the `_actions` row's `outcomeMessages.{outcome}` translation key. Every member's `Blocked-by:` is closed (#28242 / PR #28301, PR #28463, PR #28188 + #28158, PR #28195, #28211 / PR #28291, #28206 / PR #28305). No member dropped. ### Token ratchet — paid by deletion in the same file `scripts/check-skills-token-ratchet.mjs` prices every `rules/**` file (convention `ceil(utf8 bytes / 4)`, the SKILL.md stamp line excluded). The three files this fold grows sat at 1 / 0 / 3 tokens of headroom, so each addition is paid inside its file; the eval fixture is measured, not ratcheted (ruling record 6513306068). No ceiling row moves. | File | Tokens before → after (ceiling) | Lines before → after | Deletions that pay, and where the dropped text still lives | |:--|:--|:--|:--| | `skills/objectstack-ui/rules/actions.md` | 2258 → 2255 (2259) | 204 → 179 | ① the bare-CEL paragraph under the bullet list folds into the `visible` bullet (same rule, one place); ② the `ctx` section drops its blessed-name code block and the restated hook axes — `skills/objectstack-data/references/data-hooks.md:362-363`, `:588-608` carry them — and keeps the trusted-execution and ADR-0095 `positions` rules in one paragraph; ③ the `openIn` / `opensInNewTab` prose, the "two mechanisms" lead-in, the new-tab-via-`params` warning and the field-backed-params paragraph are condensed (every rule kept: static vs computed URL, pre-open, `newTabUrl` shape and refusal, own-auth endpoint, the checkbox mis-render, `objectOverride`, `defaultFromRow`); ④ two "Always"/"Best practice" lead-ins trimmed | | `skills/objectstack-ui/rules/list-views.md` | 3129 → 3108 (3129) | 311 → 309 | the "Key rule" blockquote (groupByField is a select, one column per option) folds into the example's own comment and the new `lanes` note; the ADR-0047 bullets "toggle is deprecated" and "the switcher renders as a compact dropdown … a single-entry whitelist locks the visualization" fold into the two code comments that already name `element` and `allowedVisualizations` | | `skills/objectstack-ui/rules/dashboards.md` | 6259 → 6260 (6262) | 464 → 463 | the "Engine support" bullet (per-driver bucket functions: `to_char`, `date_format`, `strftime`, `$dateToString`, `bucketDateKey`) and the "Studio's Dashboard Widget Inspector …" bullet — both describe the engine and the Studio, not anything an author writes; the bucket-label table above keeps the author-facing fact | | `skills/objectstack-i18n/SKILL.md` | 5571 → 5646 (6333) | 524 → 524 | none needed (687 headroom) | | `skills/objectstack-ui/evals/views-apps-actions-pages.json` | 2108 → 2320 (not ratcheted) | 105 → 115 | eval 11 added | Whole package: every `skills/*/SKILL.md` summed, 4607 → 4607 lines, 226235 → 226537 bytes; the gate's bundle total (all shipped files, its own convention) 219828 → 220092 tokens (the before figure is the after figure minus the five per-file deltas above; the gate prints the after figure). ## Tests - `node scripts/check-skills-token-ratchet.mjs` — exit 0: *56 authored bundle file(s) within their ceilings*; the four changed authored files read headroom 4 / 21 / 2 / 687. - `skills/objectstack-ui/evals/views-apps-actions-pages.json` stays valid JSON (parsed after the edit); eval 11 asks for a param dialog whose button reads the verb and asserts `submitText: 'Log Call'` beside `params`, refusing `submitLabel` / `confirmLabel` / `confirmText`. - The two `os:check` blocks this PR edits (`ReassignLeadAction` gains `submitText`; the report example gains `seriesBy`) compile under `pnpm --filter @objectstack/spec run check:skill-examples` — see Verification. - No ablation: a docs-only diff changes no tested behaviour. ## Acceptance notes - **`skills/objectstack-i18n/evals/bundles-locales-coverage.json` unchanged.** The order allowed an i18n eval "if the gate asks"; no derived gate asks for one, and the two new slots are table rows, so none was added. Noted, not filed. - **The `description` copy-hole example reads "owner", not "role".** The first spelling (*Choose the new role for …*, the spec's own reference example) tripped `check:role-word` (ADR-0090 D3 ratchets the word in published prose), so the example names the owner instead; the fix-up is its own commit because the branch is shared under AGENTS.md §3 and was not rewritten. - **`pressed` scope.** The card says "`record` and `user`"; the schema docblock says "the same scope as `visible` and `disabled`", and `visible`'s own describe names `record` + `current_user`, so the rule says "the same scope as `visible`" rather than naming a root the card spelled differently. - **`confirmText` beside `params` is refused** (`action.zod.ts:2248`) — a fact one step outside the six cards, carried into the `confirmText` bullet because it is the contrast #28190 asks for ("It is not `confirmText`"). Two words, no new scope. - **The deleted `ctx` code block** was the one place in `skills/` that spelled `ctx.user?.organizationId ?? ctx.session?.organizationId` as a fenced line; the expression now stands inline in the same paragraph, and `data-hooks.md` keeps the fenced form. - Out-of-scope findings: none. ## Verification All readings at head `162680ed` (worktree `/home/user/objectstack-28243`, base `origin/main` 82b29fe). <details><summary>Builds that the three dist-reading gates needed (under <code>scripts/pm/os-verify-lock.sh</code>)</summary> - `pnpm --filter '@objectstack/lint...' build` — VERDICT command-exit 0, held the lock 102 s. - `pnpm build --concurrency=2` (127 packages in scope, docs excluded) — VERDICT command-exit 0, held the lock 12m28s, *Tasks: 116 successful, 116 total*. Needed because `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:skill-examples` each exited 3 (PREREQUISITE NOT MET: no `dist/` for 89 packages, no `packages/objectql/dist/core.mjs`, no `.d.ts` under `packages/client-react/dist`) on the first sweep; they were re-run after the build and recorded with their re-run codes. </details> <details><summary>Derived gates (<code>dispatch-gates.mjs --commands --repo objectstack-ai/objectstack</code>, 45 lines at 162680e, each run with stdin closed and its exit captured before any pipe)</summary> Every one of the 45 lines is `:: exit 0`. The record is `/mnt/project-files/skills-seat-1/r2026-10-31/out/28243-gates-ran-162680ed.list` in the seat's project folder. `dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` on that record: *45 derived famil(ies) accounted for — 45 run, 0 NOT-MEASURED (a DERIVED zero — all 45 recorded an exit code and none of them is 3)*, exit 0. Readings worth quoting: `node scripts/check-skills-token-ratchet.mjs` — *56 authored bundle file(s) within their ceilings; 10 generator-owned file(s) and 28 eval fixture(s) measured, not ratcheted*; `pnpm check:role-word` — exit 0 at this head (exit 1 at 5c2fb7d on the first `description` example, fixed in 162680e); `pnpm check:skill-identifier-liveness`, `pnpm check:pm-skill-id-lint`, `pnpm check:doc-authoring`, `pnpm check:skill-frame-sync`, `pnpm check:corpus-claim-drift`, `pnpm --filter @objectstack/spec run check:skill-docs` — exit 0. </details> <details><summary>Owed outside the derivation: the <code>os:check</code> example compile</summary> `pnpm --filter @objectstack/spec run check:skill-examples` — exit 0: *277 marked example(s) across 113 file(s), 3 surface(s)* … *277 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them*. The two blocks this PR edits (`ReassignLeadAction` with `submitText`, the `PipelineCoverageReport` with `seriesBy`) are among them. Recorded in `28243-gates-ran-162680ed-extras.list` beside the derived record. </details> Every other family is CI's: see this PR's check-runs. No test suite was run locally: the diff touches no package source (`skills/**` and one changeset), so there is no affected package to filter to; `Test Core` and the rest run on the PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f1a369 commit 3b45d4a

6 files changed

Lines changed: 75 additions & 81 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): objectstack-ui and objectstack-i18n teach six keys the platform already draws — `pressed`, the `{{ }}` copy slots `description` / `helpText` / `outcomeMessages`, the param dialog's `submitText` and its translation slot, the form thank-you translation keys, `kanban.lanes` and report chart `seriesBy`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** Each key shipped in the spec and the console drew it, but no published skill taught it: an agent authoring from the skills wrote a toggle header action with no `pressed`, spelled the dialog line, a param hint or a per-outcome toast with `${…}` holes that `os validate` refuses, left the param dialog's button at "Confirm", translated no thank-you panel, could neither order kanban lanes nor drop an always-empty one, and split a chart by a second dimension with a `matrix` workaround.
11+
- **Now.** `objectstack-ui/rules/actions.md` teaches `pressed` (display-only reader state over an `attachedOnRead` block; a boolean literal is refused), names all five `{{ }}` copy slots with one example each, and teaches `submitText` beside `params` (with the `confirmText` contrast and the refused spellings); its `ReassignLeadAction` example sets `submitText`, and eval 11 asks for a param dialog whose button reads the verb. `rules/list-views.md` shows `kanban.lanes` in the kanban example — a choice of lanes and their order, not a record filter. `rules/dashboards.md` sets `seriesBy` on the report chart example and says where it is refused (an undeclared dimension, the `xAxis` dimension, a single-series type). `objectstack-i18n/SKILL.md` lists `_views.{view}.submitBehavior.title` / `.message` and `_actions.{action}.submitText`.
12+
- Every file stays at or under its token ceiling: the actions, list-views and dashboards additions are paid for by condensing text in the same file (the `ctx` section's blessed-name code block, which restates the objectstack-data hooks reference; two ADR-0047 renderer notes folded into the code comments they repeat; two engine- and Studio-facing notes that told an author nothing to write). No ceiling moves, no frontmatter changes, no schema, key or behaviour changes.

‎skills/objectstack-i18n/SKILL.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ All translatable content for a single object is aggregated under
160160
|:--------|:------|
161161
| `label` / `pluralLabel` / `description` | Object-level text (every key optional) |
162162
| `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 |
163-
| `_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`) |
164-
| `_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}` |
163+
| `_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), `submitBehavior.title` / `submitBehavior.message` (the thank-you panel of a form whose `submitBehavior` is `kind: 'thank-you'`; only the copy the form authors is translated, and the other kinds carry none), `emptyState.title` / `emptyState.message`, `bulkActions.{def_name}` (`label`, `confirmText`, `confirmLabel`, `params`) |
164+
| `_actions.{action_name}` | `label`, `description`, `confirmText`, `submitText` (the param dialog's button verb — only under an action that declares `params`), `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}` |
165165
| `_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) |
166166
| `_tabs.{tab_name}` | Filter-preset tab `label` (`ViewTabSchema.name`) |
167167
| `_validations.{rule_name}` | `message` only — the sentence a rejected write returns (replaces the retired `validationMessages`) |

‎skills/objectstack-ui/evals/views-apps-actions-pages.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@
100100
"must_contain": ["type: 'report'"],
101101
"must_not_contain": ["reportName:"]
102102
}
103+
},
104+
{
105+
"id": 11,
106+
"prompt": "Add a 'Log Call' action on `contact` that asks for the call notes and the outcome in a dialog, and make the dialog's button read Log Call instead of Confirm.",
107+
"expected_output": "One `defineAction` with field-backed `params` (`call_notes`, `call_outcome`) and `submitText: 'Log Call'` beside them — the verb on the param dialog's submit button, an I18nLabel translated at `objects.contact._actions.log_call.submitText`. Not `confirmText` (the message of a param-less action's confirm dialog, refused beside `params`), not `submitLabel` / `confirmLabel` (refused, naming `submitText`), and never blank.",
108+
"files": [],
109+
"assertions": {
110+
"must_contain": ["params", "submitText: 'Log Call'"],
111+
"must_not_contain": ["submitLabel", "confirmLabel", "confirmText"]
112+
}
103113
}
104114
]
105115
}

‎skills/objectstack-ui/rules/actions.md‎

Lines changed: 34 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,16 @@
1515

1616
## Visibility, Disable & Feedback
1717

18-
- `visible` — CEL predicate (prefer the `P\`...\`` tagged template); when false the action is **hidden**.
18+
- `visible` — a bare CEL predicate over the current record (prefer the `P\`...\`` template; `record.<field>` resolves identically on every surface, never `${…}`- or `{…}`-wrapped — see `objectstack-formula`); when false the action is **hidden**.
1919
- `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.
20-
- `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`.
21-
- `confirmText` — set for any destructive or irreversible operation.
22-
- `successMessage` / `errorMessage` — toast copy on success / failure. Always set `successMessage` for non-obvious outcomes; without it the UI shows a generic "Action completed" toast.
23-
- 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`.
20+
- `disabledReason` — an I18nLabel (a 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`.
21+
- `pressed` — a CEL predicate over the same scope as `visible` that marks the action as already done by this reader: while TRUE the record header draws it pressed (`aria-pressed`). Display-only, never a gate; a boolean literal is refused (a constant is a style, not a reader's state), and `active` / `activeWhen` / `pressedWhen` are refused naming this key. A per-reader fact (the reader's vote) is a block the object declares under `attachedOnRead`, filled per caller by a service or `afterFind` hook, read as `has(record.viewer) && record.viewer.vote == 'helpful'`.
22+
- `confirmText` — set for any destructive or irreversible operation; the confirm dialog of a param-less action. A registered action is refused it beside a non-empty `params` (put the question on `description`); a view's `bulkActionDefs` entry pairs the two by its own contract.
23+
- `submitText` — the verb on the param dialog's submit button ("Send", "Log Call") in place of "Confirm": an I18nLabel, refused blank and refused without a non-empty `params` (no dialog opens); `submitLabel` / `confirmLabel` are refused naming this key.
24+
- `successMessage` / `errorMessage` — toast copy on success / failure. Set `successMessage` for non-obvious outcomes; without it the UI shows a generic "Action completed" toast.
25+
- Copy holes are `{{ }}`: `confirmText`, `description` (`'Choose the new owner of {{ record.name }}.'`) and a param's `helpText` (`'Why {{ record.name }} is paused'`) read `record`; `successMessage` and each `outcomeMessages` entry (`resent: 'Invitation resent to {{ result.email }}'`) read `result` (the handler's answer) and `record`; `errorMessage` takes none. A `bulkActionDefs` entry's `confirmText` reads `{{ count }}` and its params by bare name (`{{ tier }}`). In all of them `${record.name}`, `{name}`, logic in a hole and a blank sentence fail `os validate`.
2426
- `undoable: true` — an **Undo** in the success toast (and `Ctrl+Z`). Snapshot: runtime on `operation: 'update'`, console on `type: 'api'`; else refused.
2527

26-
Predicates are **bare CEL** — `record.status == "converted"`, evaluated against
27-
the current record. `record.<field>` resolves identically on every surface
28-
(`record_header`, `list_item`, …); prefer it over the bare-field form. Never
29-
wrap a predicate in `${…}` or `{…}` braces (see `objectstack-formula`).
30-
3128
<!-- os:check -->
3229
```typescript
3330
import { defineAction } from '@objectstack/spec/ui';
@@ -43,6 +40,7 @@ export const ReassignLeadAction = defineAction({
4340
disabled: P`record.status == "converted"`,
4441
disabledReason: 'A converted lead is reassigned from its opportunity.',
4542
params: [{ field: 'assigned_to', required: true }],
43+
submitText: 'Reassign',
4644
undoable: true,
4745
successMessage: 'Lead {{ record.name }} reassigned.',
4846
errorMessage: "Couldn't reassign this lead — try again.",
@@ -74,7 +72,7 @@ export const ConvertLeadAction = defineAction({
7472
});
7573
```
7674

77-
**Script-typed action** (collect params, then execute server body):
75+
**Script-typed action** (params, then a server body):
7876

7977
<!-- os:check -->
8078
```typescript
@@ -112,40 +110,22 @@ export const AddToCampaignAction = defineAction({
112110

113111
### Action body context (`ctx`)
114112

115-
A server-side action `body` (and a registered function `handler`) receives a
116-
`ctx` with `input` (the modal params), `record` (the target row, when a
117-
`recordId` is in scope), `api` (scoped cross-object CRUD), and the caller
118-
identity. Read the caller's active organization under the **blessed**
119-
`organizationId` name — the same value as the `organization_id` column and
120-
`current_user.organizationId` in RLS:
121-
122-
```typescript
123-
// ✅ Blessed — identical to the hook surface (ctx.user / ctx.session)
124-
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
125-
```
126-
127-
Action bodies execute **trusted** (the `ctx.engine` / `ctx.api` facade bypasses
128-
RLS/FLS), so a body that must scope by org reads it from `ctx` explicitly.
129-
`ctx.user` is `undefined` for a context-less / self-invoked call. Same two
130-
isolation axes, same blessed name, same `undefined` cases as hooks — see the
131-
objectstack-data hooks reference.
132-
133-
The caller's position names are on `ctx.session.positions` (absent, not empty,
134-
when the caller holds none). **This array is not an authorization input**:
135-
`positions.includes('admin')` is a defect under a blessed name — ask the
136-
security service for privilege (ADR-0095).
113+
A server-side `body` (or a registered `handler`) receives `ctx` with `input` (the dialog params), `record` (the target row, when a
114+
`recordId` is in scope), `api` (scoped cross-object CRUD) and the caller. Read
115+
the active organization as `ctx.user?.organizationId ?? ctx.session?.organizationId`
116+
— the blessed name (same axes and `undefined` cases as hooks, objectstack-data).
117+
Bodies execute **trusted** (`ctx.api` /
118+
`ctx.engine` bypass RLS/FLS), so a body that must scope by org reads it from
119+
`ctx` explicitly. `ctx.session.positions` (absent when the caller holds none) is
120+
**not an authorization input** — ask the security service for privilege (ADR-0095).
137121

138122
## Opening in a New Tab (`openIn` / `opensInNewTab` / `newTabUrl`)
139123

140-
There are **two** mechanisms here. Pick by whether the URL is static or computed:
141-
142124
### `openIn: 'new-tab'` — simplest case (static `target`)
143125

144-
When you have a **static** `target` URL (relative or absolute) you just want
145-
opened in a new tab, set `openIn: 'new-tab'` on a `type: 'url'` action. No
146-
handler, no synchronous pre-open. `openIn: 'self'` forces in-place navigation;
147-
omit it and external/absolute URLs open in a new tab while relative URLs
148-
navigate in place.
126+
A **static** `target` URL on a `type: 'url'` action: `openIn: 'new-tab'` opens
127+
it in a new tab, `'self'` forces in-place navigation; omitted, absolute URLs open
128+
in a new tab and relative ones navigate in place.
149129

150130
<!-- os:check -->
151131
```typescript
@@ -163,14 +143,12 @@ export const PrintA3Action = defineAction({
163143

164144
### `opensInNewTab` + `newTabUrl` — async / computed redirect (SSO)
165145

166-
For actions whose redirect URL is **computed after a fetch** (SSO and SSO-like
167-
handlers), set `opensInNewTab: true`. The renderer pre-opens the tab
168-
**synchronously** on click so popup blockers don't fire, then navigates it to
169-
the handler's returned `redirectUrl`. To skip the round-trip, add `newTabUrl`
170-
— a backend path starting with `/` (`{recordId}` placeholder; an external URL
171-
is refused, use `openIn` above). Valid **only** with `opensInNewTab: true`; the
172-
endpoint must enforce its own auth (the new tab carries no in-app session
173-
context).
146+
A redirect URL **computed after a fetch** (SSO-like handlers): `opensInNewTab:
147+
true` pre-opens the tab synchronously on click (no popup blocker), then navigates
148+
it to the handler's `redirectUrl`. `newTabUrl` skips the round-trip — a backend
149+
path starting with `/` (`{recordId}` placeholder; an external URL is refused, use
150+
`openIn`), valid only with `opensInNewTab: true`; the endpoint enforces its own
151+
auth (the tab carries no in-app session).
174152

175153
```typescript
176154
export const OpenInvoicePdfAction = defineAction({
@@ -187,18 +165,15 @@ export const OpenInvoicePdfAction = defineAction({
187165

188166
> A printable document (an invoice) is a page that declares `print` — [Print pages](./pages.md#print-pages).
189167
190-
> ⚠️ **Never express new-tab behavior via `params`.** `params` is exclusively
191-
> `ActionParam[]` for collecting **user input**. Writing an object form like
192-
> `params: { newTab: true }` fails the zod build outright; the array form
193-
> `params: [{ name: 'newTab', type: 'checkbox' }]` *builds* but mis-renders as a
194-
> user-facing checkbox in the param-collection dialog. Use one of the two mechanisms above.
168+
> ⚠️ **Never express new-tab behavior via `params`** — it is `ActionParam[]` for
169+
> **user input** only: `params: [{ name: 'newTab', type: 'checkbox' }]` builds and
170+
> draws a checkbox in the dialog.
195171
196172
## Action Parameter Patterns
197173

198-
Prefer **field-backed** params (`{ field: 'email' }`) over inline declarations
199-
— the runtime resolves label (i18n), type, validation, options, placeholder,
200-
and widget mapping from object metadata. Use `objectOverride` to reference a
201-
field from a different object. Set `defaultFromRow: true` to pre-fill from
202-
the selected row in `list_item` contexts.
174+
Prefer **field-backed** params (`{ field: 'email' }`): label (i18n), type,
175+
validation, options, placeholder and widget come from the object metadata;
176+
`objectOverride` borrows a field of another object, and `defaultFromRow: true`
177+
pre-fills from the selected row in `list_item` contexts.
203178

204-
> **Best practice:** set `refreshAfter: true` whenever the action mutates the current record.
179+
> Set `refreshAfter: true` whenever the action mutates the current record.

0 commit comments

Comments
 (0)