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-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index b0212beed57..9705c10eada 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -160,8 +160,8 @@ 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}` | +| `_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`) | | `_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 efc9f2f6ce9..aa8afa3ead2 100644 --- a/skills/objectstack-ui/rules/actions.md +++ b/skills/objectstack-ui/rules/actions.md @@ -15,19 +15,16 @@ ## 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`. -- `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`. +- `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. 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`. - `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'; @@ -43,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.", @@ -74,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 @@ -112,40 +110,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 (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). ## 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 +143,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 +165,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. ## 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. diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index c0d19a6d032..ffbb79f6e2f 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` @@ -369,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. @@ -377,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. --- @@ -404,7 +396,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 +416,12 @@ 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: 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 d9859d3202b..d1102caa9d0 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 one of the field's inline options, or listed twice, fails `os validate`. ---