From 227a4be161194f2528d828c9ca45382cf05036c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:11:49 +0000 Subject: [PATCH 1/8] docs(skills): the objectstack-ui actions rule teaches `pressed`, the reader's own toggle state A record header action can declare `pressed`, a CEL predicate over the same scope as `visible` that the header draws as `aria-pressed` while it holds. The rule says it is display only, that a boolean literal is refused, which spellings rename onto it, and how a per-reader fact reaches it through an `attachedOnRead` block. The addition is paid for inside the file: the bare-CEL paragraph folds into the `visible` bullet, the `ctx` section drops the blessed-name code block that restates the objectstack-data hooks reference, and the new-tab and param-pattern prose is condensed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- skills/objectstack-ui/rules/actions.md | 85 +++++++++----------------- 1 file changed, 29 insertions(+), 56 deletions(-) diff --git a/skills/objectstack-ui/rules/actions.md b/skills/objectstack-ui/rules/actions.md index efc9f2f6ce9..f7fd1365a8d 100644 --- a/skills/objectstack-ui/rules/actions.md +++ b/skills/objectstack-ui/rules/actions.md @@ -15,19 +15,15 @@ ## Visibility, Disable & Feedback -- `visible` — CEL predicate (prefer the `P\`...\`` tagged template); when false the action is **hidden**. +- `visible` — a bare CEL predicate over the current record (prefer the `P\`...\`` template; `record.` resolves identically on every surface, never `${…}`- or `{…}`-wrapped — see `objectstack-formula`); 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`. +- `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`. +- `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'`. - `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. +- `successMessage` / `errorMessage` — toast copy on success / failure. 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`. - `undoable: true` — an **Undo** in the success toast (and `Ctrl+Z`). Snapshot: runtime on `operation: 'update'`, console on `type: 'api'`; else refused. -Predicates are **bare CEL** — `record.status == "converted"`, evaluated against -the current record. `record.` resolves identically on every surface -(`record_header`, `list_item`, …); prefer it over the bare-field form. Never -wrap a predicate in `${…}` or `{…}` braces (see `objectstack-formula`). - ```typescript import { defineAction } from '@objectstack/spec/ui'; @@ -112,40 +108,22 @@ export const AddToCampaignAction = defineAction({ ### Action body context (`ctx`) -A server-side action `body` (and a registered function `handler`) receives a -`ctx` with `input` (the modal params), `record` (the target row, when a -`recordId` is in scope), `api` (scoped cross-object CRUD), and the caller -identity. Read the caller's active organization under the **blessed** -`organizationId` name — the same value as the `organization_id` column and -`current_user.organizationId` in RLS: - -```typescript -// ✅ Blessed — identical to the hook surface (ctx.user / ctx.session) -const org = ctx.user?.organizationId ?? ctx.session?.organizationId; -``` - -Action bodies execute **trusted** (the `ctx.engine` / `ctx.api` facade bypasses -RLS/FLS), so a body that must scope by org reads it from `ctx` explicitly. -`ctx.user` is `undefined` for a context-less / self-invoked call. Same two -isolation axes, same blessed name, same `undefined` cases as hooks — see the -objectstack-data hooks reference. - -The caller's position names are on `ctx.session.positions` (absent, not empty, -when the caller holds none). **This array is not an authorization input**: -`positions.includes('admin')` is a defect under a blessed name — ask the -security service for privilege (ADR-0095). +A server-side `body` (or a registered `handler`) receives `ctx` with `input` (the dialog params), `record` (the target row, when a +`recordId` is in scope), `api` (scoped cross-object CRUD) and the caller. Read +the active organization as `ctx.user?.organizationId ?? ctx.session?.organizationId` +— the blessed name, with the same isolation axes and `undefined` cases as hooks +(objectstack-data hooks reference). Bodies execute **trusted** (`ctx.api` / +`ctx.engine` bypass RLS/FLS), so a body that must scope by org reads it from +`ctx` explicitly. `ctx.session.positions` (absent when the caller holds none) is +**not an authorization input** — ask the security service for privilege (ADR-0095). ## Opening in a New Tab (`openIn` / `opensInNewTab` / `newTabUrl`) -There are **two** mechanisms here. Pick by whether the URL is static or computed: - ### `openIn: 'new-tab'` — simplest case (static `target`) -When you have a **static** `target` URL (relative or absolute) you just want -opened in a new tab, set `openIn: 'new-tab'` on a `type: 'url'` action. No -handler, no synchronous pre-open. `openIn: 'self'` forces in-place navigation; -omit it and external/absolute URLs open in a new tab while relative URLs -navigate in place. +A **static** `target` URL on a `type: 'url'` action: `openIn: 'new-tab'` opens +it in a new tab, `'self'` forces in-place navigation; omitted, absolute URLs open +in a new tab and relative ones navigate in place. ```typescript @@ -163,14 +141,12 @@ export const PrintA3Action = defineAction({ ### `opensInNewTab` + `newTabUrl` — async / computed redirect (SSO) -For actions whose redirect URL is **computed after a fetch** (SSO and SSO-like -handlers), set `opensInNewTab: true`. The renderer pre-opens the tab -**synchronously** on click so popup blockers don't fire, then navigates it to -the handler's returned `redirectUrl`. To skip the round-trip, add `newTabUrl` -— a backend path starting with `/` (`{recordId}` placeholder; an external URL -is refused, use `openIn` above). Valid **only** with `opensInNewTab: true`; the -endpoint must enforce its own auth (the new tab carries no in-app session -context). +A redirect URL **computed after a fetch** (SSO-like handlers): `opensInNewTab: +true` pre-opens the tab synchronously on click (no popup blocker), then navigates +it to the handler's `redirectUrl`. `newTabUrl` skips the round-trip — a backend +path starting with `/` (`{recordId}` placeholder; an external URL is refused, use +`openIn`), valid only with `opensInNewTab: true`; the endpoint enforces its own +auth (the tab carries no in-app session). ```typescript export const OpenInvoicePdfAction = defineAction({ @@ -187,18 +163,15 @@ export const OpenInvoicePdfAction = defineAction({ > A printable document (an invoice) is a page that declares `print` — [Print pages](./pages.md#print-pages). -> ⚠️ **Never express new-tab behavior via `params`.** `params` is exclusively -> `ActionParam[]` for collecting **user input**. Writing an object form like -> `params: { newTab: true }` fails the zod build outright; the array form -> `params: [{ name: 'newTab', type: 'checkbox' }]` *builds* but mis-renders as a -> user-facing checkbox in the param-collection dialog. Use one of the two mechanisms above. +> ⚠️ **Never express new-tab behavior via `params`** — it is `ActionParam[]` for +> **user input** only: `params: [{ name: 'newTab', type: 'checkbox' }]` builds and +> draws a checkbox in the dialog. Use one of the two mechanisms above. ## Action Parameter Patterns -Prefer **field-backed** params (`{ field: 'email' }`) over inline declarations -— the runtime resolves label (i18n), type, validation, options, placeholder, -and widget mapping from object metadata. Use `objectOverride` to reference a -field from a different object. Set `defaultFromRow: true` to pre-fill from -the selected row in `list_item` contexts. +Prefer **field-backed** params (`{ field: 'email' }`): label (i18n), type, +validation, options, placeholder and widget come from the object metadata; +`objectOverride` borrows a field of another object, and `defaultFromRow: true` +pre-fills from the selected row in `list_item` contexts. -> **Best practice:** set `refreshAfter: true` whenever the action mutates the current record. +> Set `refreshAfter: true` whenever the action mutates the current record. From 14bf9e3f5db8d6b5f57fa63c120a03ea87032da9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:11:49 +0000 Subject: [PATCH 2/8] docs(skills): the actions rule's copy-hole bullet names `description`, param `helpText` and `outcomeMessages` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Copy holes are `{{ }}`" bullet now names all five template slots with one example each: `description` and a param's `helpText` read `record`, each `outcomeMessages` entry reads `result` and `record`, and a `${…}` spelling fails `os validate` in every one of them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- skills/objectstack-ui/rules/actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/objectstack-ui/rules/actions.md b/skills/objectstack-ui/rules/actions.md index f7fd1365a8d..4dc1dd15384 100644 --- a/skills/objectstack-ui/rules/actions.md +++ b/skills/objectstack-ui/rules/actions.md @@ -21,7 +21,7 @@ - `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'`. - `confirmText` — set for any destructive or irreversible operation. - `successMessage` / `errorMessage` — toast copy on success / failure. 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`. +- Copy holes are `{{ }}`: `confirmText`, `description` (`'Choose the new role for {{ 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`. - `undoable: true` — an **Undo** in the success toast (and `Ctrl+Z`). Snapshot: runtime on `operation: 'update'`, console on `type: 'api'`; else refused. From 8b1ba9445e75328910bdc4f40cd1c2766a77f0da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:11:49 +0000 Subject: [PATCH 3/8] docs(skills): objectstack-ui and objectstack-i18n teach the param dialog's `submitText`, with an eval An action with `params` can set `submitText`, the verb on the dialog's submit button in place of "Confirm". The actions rule says it is an I18nLabel, refused blank and refused without a non-empty `params`, that `submitLabel` / `confirmLabel` rename onto it, and how it differs from `confirmText` (now also said to be refused beside `params`). The `ReassignLeadAction` example sets it, eval 11 asks for a dialog whose button reads the verb, and the i18n `_actions` row lists the `submitText` slot. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- skills/objectstack-i18n/SKILL.md | 2 +- .../objectstack-ui/evals/views-apps-actions-pages.json | 10 ++++++++++ skills/objectstack-ui/rules/actions.md | 4 +++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index b0212beed57..c0e539c3532 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`, `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}` | +| `_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}` | | `_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 2a448de0f1b..6bf804d08d3 100644 --- a/skills/objectstack-ui/evals/views-apps-actions-pages.json +++ b/skills/objectstack-ui/evals/views-apps-actions-pages.json @@ -100,6 +100,16 @@ "must_contain": ["type: 'report'"], "must_not_contain": ["reportName:"] } + }, + { + "id": 11, + "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.", + "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.", + "files": [], + "assertions": { + "must_contain": ["params", "submitText: 'Log Call'"], + "must_not_contain": ["submitLabel", "confirmLabel", "confirmText"] + } } ] } diff --git a/skills/objectstack-ui/rules/actions.md b/skills/objectstack-ui/rules/actions.md index 4dc1dd15384..b5242f5b199 100644 --- a/skills/objectstack-ui/rules/actions.md +++ b/skills/objectstack-ui/rules/actions.md @@ -19,7 +19,8 @@ - `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 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`. - `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'`. -- `confirmText` — set for any destructive or irreversible operation. +- `confirmText` — set for any destructive or irreversible operation; the confirm dialog of a param-less action, refused beside a non-empty `params` (put the question on `description`). +- `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. - `successMessage` / `errorMessage` — toast copy on success / failure. Set `successMessage` for non-obvious outcomes; without it the UI shows a generic "Action completed" toast. - Copy holes are `{{ }}`: `confirmText`, `description` (`'Choose the new role for {{ 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`. - `undoable: true` — an **Undo** in the success toast (and `Ctrl+Z`). Snapshot: runtime on `operation: 'update'`, console on `type: 'api'`; else refused. @@ -39,6 +40,7 @@ export const ReassignLeadAction = defineAction({ disabled: P`record.status == "converted"`, disabledReason: 'A converted lead is reassigned from its opportunity.', params: [{ field: 'assigned_to', required: true }], + submitText: 'Reassign', undoable: true, successMessage: 'Lead {{ record.name }} reassigned.', errorMessage: "Couldn't reassign this lead — try again.", From c2f1483b01a991fac0acb1cf7b549575fa06a98b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:11:50 +0000 Subject: [PATCH 4/8] docs(skills): the i18n `_views` row lists the form thank-you panel keys `objects.{object}._views.{view}.submitBehavior.title` / `.message` translate 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. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- skills/objectstack-i18n/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index c0e539c3532..9705c10eada 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -160,7 +160,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`) | +| `_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`) | | `_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}` | | `_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`) | From 4c44bdfb1ff61e39f9f171f7a22a660f1a537f6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:11:50 +0000 Subject: [PATCH 5/8] docs(skills): the objectstack-ui list-views rule teaches `kanban.lanes` The kanban example shows `lanes`, the stored option values to draw in that order, and the note under it says it is a choice of lanes, not a record filter (an unlisted value lands in `Uncategorized`; filter the view to keep records off the board) and that a non-option or repeated value fails `os validate`. Paid for by folding two ADR-0047 renderer notes into the code comments they repeat. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- skills/objectstack-ui/rules/list-views.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/skills/objectstack-ui/rules/list-views.md b/skills/objectstack-ui/rules/list-views.md index d9859d3202b..778179c1aed 100644 --- a/skills/objectstack-ui/rules/list-views.md +++ b/skills/objectstack-ui/rules/list-views.md @@ -117,7 +117,7 @@ toggles the user combines at runtime: ```typescript userFilters: { - element: 'dropdown', // 'dropdown' | 'tabs' | 'toggle' + element: 'dropdown', // 'dropdown' | 'tabs' ('toggle' is deprecated) fields: [ { field: 'status' }, // options/labels inferred from field def { field: 'priority', showCount: true }, @@ -125,7 +125,7 @@ userFilters: { }, // Runtime visualization whitelist (Airtable "Appearance → Visualizations"): -appearance: { allowedVisualizations: ['grid', 'kanban', 'gallery'] }, +appearance: { allowedVisualizations: ['grid', 'kanban', 'gallery'] }, // one entry = locked, no switcher ``` Rules: @@ -150,11 +150,6 @@ Rules: so most views need no filter elements at all. - `userFilters: { element: 'dropdown' }` (no `fields`) is valid shorthand: the renderer fills the field list from the object's select/boolean fields. -- `element` is `dropdown` or `tabs`; `toggle` is **deprecated** (ADR-0047 §3.4a) - — it stays in the enum for back-compat rendering, but author `dropdown`/`tabs`. -- The visualization switcher renders as a compact dropdown in the toolbar's - right cluster. Authors only control the `allowedVisualizations` whitelist; - a single-entry whitelist locks the visualization (no switcher). ### Toolbar Search (`searchableFields`, ADR-0061) @@ -272,7 +267,8 @@ top-level `groupBy`**. The top-level `columns` is required on every list view data: { provider: 'object', object: 'project_task' }, columns: ['title', 'assignee', 'priority'], // required on every list view kanban: { - groupByField: 'status', // one board column per select option + groupByField: 'status', // a select field — one lane per option + lanes: ['todo', 'in_progress', 'review'], // option VALUES to draw, in this order summarizeField: 'estimate_hours', // optional — summed at the top of each column columns: ['title', 'assignee', 'priority'], // fields shown on each card }, @@ -280,8 +276,10 @@ top-level `groupBy`**. The top-level `columns` is required on every list view } ``` -> **Key rule:** `kanban.groupByField` should be a `select` type with -> well-defined options. Each option becomes a column on the board. +> `lanes` chooses the lanes and their order, not the records: a record whose +> value is unlisted lands in `Uncategorized`, so filter the view to keep records +> off the board. Omit it to draw every option in its own order; a value that is +> not an option, or listed twice, fails `os validate`. --- From 5c2fb7da3949d2986c4b8786ccae68fd8e1f1f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:11:50 +0000 Subject: [PATCH 6/8] docs(skills): the objectstack-ui dashboards rule teaches report chart `seriesBy`; changeset for the fold The report example splits its bar chart by `close_date`, and the note says `seriesBy` must name a declared dataset dimension other than `xAxis` and is refused on the single-series chart types. Paid for by dropping two notes that described the engine and the Studio rather than anything an author writes. The changeset covers the six teaching edits of this fold. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- .changeset/28243-skills-ui-i18n-teach-six-keys.md | 12 ++++++++++++ skills/objectstack-ui/rules/dashboards.md | 15 +++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 .changeset/28243-skills-ui-i18n-teach-six-keys.md diff --git a/.changeset/28243-skills-ui-i18n-teach-six-keys.md b/.changeset/28243-skills-ui-i18n-teach-six-keys.md new file mode 100644 index 00000000000..cdf8dd93042 --- /dev/null +++ b/.changeset/28243-skills-ui-i18n-teach-six-keys.md @@ -0,0 +1,12 @@ +--- +'@objectstack/skills': patch +--- + +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` + +Clause-②: no +Metadata-protocol: none + +- **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. +- **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`. +- 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. diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index c0d19a6d032..549bf5f333b 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -133,10 +133,6 @@ dataset, so Level B only surfaces in Studio previews and hand-coded react-page `logarithmic`, `title`); a second `yAxis` entry plus `series[].yAxis: 'right'` gives a dual axis; `series[].stack` groups a stacked bar; `annotations` draw reference lines/bands. -- Studio's Dashboard Widget Inspector can author per-widget `dataset`, - `dimensions`, and `values`; curated metadata-admin forms merge - server-only fields back into the payload, so saving through Studio should - not drop newer schema fields. - The analytics runtime applies SecurityPlugin read scope via `security.getReadFilter`, so dashboard/report datasets remain RLS-aware. @@ -321,9 +317,6 @@ const signedByMonth: DashboardWidget = { id: 'signed_by_month', type: 'line', | `'quarter'` | `YYYY-Qn` | | `'year'` | `YYYY` | -* **Engine support** — drivers emit the bucket as a label (`2026-01`), not - an instant: Postgres `to_char`, MySQL `date_format`, SQLite `strftime`, - MongoDB `$dateToString`, in-memory `bucketDateKey`. * **Human labels are automatic** — the analytics layer formats the bucket value to the label above, and resolves `select`/`lookup` dimension values to their option label / related-record name. Measures carry their `label` + `format` @@ -404,7 +397,8 @@ export const PipelineCoverageReport = defineReport({ dateRange: { dimension: 'close_date', defaultRange: 'this_year' }, exportOptions: { formats: ['csv', 'xlsx'] }, // the download; optional `includeHeaders`, `fileNamePrefix` order: [{ by: 'amount_sum', direction: 'desc' }], // by measure; dates default to chronological - chart: { type: 'bar', xAxis: 'forecast_category', yAxis: 'amount_sum' }, + // `seriesBy`: one bar per quarter inside each category. + chart: { type: 'bar', xAxis: 'forecast_category', yAxis: 'amount_sum', seriesBy: 'close_date' }, }); ``` @@ -423,6 +417,11 @@ export const PipelineCoverageReport = defineReport({ > **`exportOptions`** declares the download, never who may download — that is the > base object's permission-set `allowExport`; a `joined` report takes neither > `dateRange` nor `exportOptions`. +> **`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 +> (publish refuses an unknown name) other than `xAxis`, and is refused on the +> single-series types `pie` / `donut` / `funnel` / `treemap` / `sankey`; with it, +> `series[].name` names one of its values. --- From 162680edace8bac25e4827a8113451227f805d78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:14:58 +0000 Subject: [PATCH 7/8] docs(skills): the `description` copy-hole example reads "owner", not the reserved word The check:role-word ratchet counts "role" in published prose (ADR-0090 D3), so the example sentence in the copy-hole bullet names the owner instead. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- skills/objectstack-ui/rules/actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/objectstack-ui/rules/actions.md b/skills/objectstack-ui/rules/actions.md index b5242f5b199..7a764830d72 100644 --- a/skills/objectstack-ui/rules/actions.md +++ b/skills/objectstack-ui/rules/actions.md @@ -22,7 +22,7 @@ - `confirmText` — set for any destructive or irreversible operation; the confirm dialog of a param-less action, refused beside a non-empty `params` (put the question on `description`). - `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. - `successMessage` / `errorMessage` — toast copy on success / failure. Set `successMessage` for non-obvious outcomes; without it the UI shows a generic "Action completed" toast. -- Copy holes are `{{ }}`: `confirmText`, `description` (`'Choose the new role for {{ 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`. +- 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`. - `undoable: true` — an **Undo** in the success toast (and `Ctrl+Z`). Snapshot: runtime on `operation: 'update'`, console on `type: 'api'`; else refused. From fa3cef9cf242b78e8c592e2465fda2bcb13aedc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:57:59 +0000 Subject: [PATCH 8/8] =?UTF-8?q?docs(skills):=20contract-review=20should-fi?= =?UTF-8?q?xes=20on=20the=20fold=20=E2=80=94=20confirmText=20scope,=20lane?= =?UTF-8?q?s=20refusal,=20seriesBy=20pivot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions.md: the `confirmText`-beside-`params` refusal is the registered action's; a view's `bulkActionDefs` entry pairs the two by its own contract (action.zod.ts 2240-2262). Paid by trimming the ctx hooks cross-reference, one lead-in and a "two mechanisms above" sentence. - list-views.md: the unknown-lane refusal is judged against the group field's inline options (validate-list-view-field-refs.ts 370-394), so the note says "not one of the field's inline options". - dashboards.md: the `seriesBy` note names the pivot it replaces (a second `columns` dimension only pivots the table) instead of "not a matrix workaround" under a `type: 'matrix'` example. Paid by dropping the Looker / Power BI aside and the "drill are unified" restatement. Token ratchet: actions.md 2256/2259, list-views.md 3114/3129, dashboards.md 6248/6262. Review record 6550826945. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz4G9FrLR4TRExx6gn --- skills/objectstack-ui/rules/actions.md | 10 +++++----- skills/objectstack-ui/rules/dashboards.md | 8 ++++---- skills/objectstack-ui/rules/list-views.md | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/skills/objectstack-ui/rules/actions.md b/skills/objectstack-ui/rules/actions.md index 7a764830d72..aa8afa3ead2 100644 --- a/skills/objectstack-ui/rules/actions.md +++ b/skills/objectstack-ui/rules/actions.md @@ -19,7 +19,7 @@ - `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 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`. - `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'`. -- `confirmText` — set for any destructive or irreversible operation; the confirm dialog of a param-less action, refused beside a non-empty `params` (put the question on `description`). +- `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. - `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. - `successMessage` / `errorMessage` — toast copy on success / failure. Set `successMessage` for non-obvious outcomes; without it the UI shows a generic "Action completed" toast. - 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`. @@ -72,7 +72,7 @@ export const ConvertLeadAction = defineAction({ }); ``` -**Script-typed action** (collect params, then execute server body): +**Script-typed action** (params, then a server body): ```typescript @@ -113,8 +113,8 @@ export const AddToCampaignAction = defineAction({ A server-side `body` (or a registered `handler`) receives `ctx` with `input` (the dialog params), `record` (the target row, when a `recordId` is in scope), `api` (scoped cross-object CRUD) and the caller. Read the active organization as `ctx.user?.organizationId ?? ctx.session?.organizationId` -— the blessed name, with the same isolation axes and `undefined` cases as hooks -(objectstack-data hooks reference). Bodies execute **trusted** (`ctx.api` / +— the blessed name (same axes and `undefined` cases as hooks, objectstack-data). +Bodies execute **trusted** (`ctx.api` / `ctx.engine` bypass RLS/FLS), so a body that must scope by org reads it from `ctx` explicitly. `ctx.session.positions` (absent when the caller holds none) is **not an authorization input** — ask the security service for privilege (ADR-0095). @@ -167,7 +167,7 @@ export const OpenInvoicePdfAction = defineAction({ > ⚠️ **Never express new-tab behavior via `params`** — it is `ActionParam[]` for > **user input** only: `params: [{ name: 'newTab', type: 'checkbox' }]` builds and -> draws a checkbox in the dialog. Use one of the two mechanisms above. +> draws a checkbox in the dialog. ## Action Parameter Patterns diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index 549bf5f333b..ffbb79f6e2f 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -362,7 +362,7 @@ behind it; **drill-to-record** opens one record. object's *full* list page (sort / bulk-select / export / shareable URL), scoped by the same drill filter. The in-place drawer is the default (peek without losing the dashboard); the escape hatch escalates when the user wants - the full surface — the Looker / Power BI "see records → open page" model. + the full surface. * **`metric` / `chart` widgets are not click-drillable** in the dataset form (they render the aggregate only; `compareTo` still applies). Surface the detail through a `table` / `pivot` widget instead. @@ -370,7 +370,6 @@ behind it; **drill-to-record** opens one record. **Reports drill the same way.** A `summary` / `matrix` report (`drilldown` defaults `true`) opens the identical in-place drawer on row/cell click — peek the records, click a row to open one, or "Open in list →" for the full list page. -Dashboard and report drill are unified. --- @@ -418,8 +417,9 @@ export const PipelineCoverageReport = defineReport({ > base object's permission-set `allowExport`; a `joined` report takes neither > `dateRange` nor `exportOptions`. > **`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 -> (publish refuses an unknown name) other than `xAxis`, and is refused on the +> dataset dimension: the chart's own split, where a second `columns` dimension +> only pivots the table. It must name a declared dimension (publish refuses an +> unknown name) other than `xAxis`, and is refused on the > single-series types `pie` / `donut` / `funnel` / `treemap` / `sankey`; with it, > `series[].name` names one of its values. diff --git a/skills/objectstack-ui/rules/list-views.md b/skills/objectstack-ui/rules/list-views.md index 778179c1aed..d1102caa9d0 100644 --- a/skills/objectstack-ui/rules/list-views.md +++ b/skills/objectstack-ui/rules/list-views.md @@ -279,7 +279,7 @@ top-level `groupBy`**. The top-level `columns` is required on every list view > `lanes` chooses the lanes and their order, not the records: a record whose > value is unlisted lands in `Uncategorized`, so filter the view to keep records > off the board. Omit it to draw every option in its own order; a value that is -> not an option, or listed twice, fails `os validate`. +> not one of the field's inline options, or listed twice, fails `os validate`. ---