Skip to content

Commit 591a796

Browse files
skills(objectstack-ui): teach list-view grouping and orderBy, the duration measure, action copy holes, and a flow action's onSuccess (#26385)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8FnjvddcEnXtkGQ5qFJBDTp)_ Fixes #26295 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:objectstack-ui 技能包补上 18.2 的四条教学 —— 列表 `grouping.fields[]` 与 `orderBy`、数据集 `duration` 度量、动作文案的 `{{ }}` 占位(各键的根、bulk 的 `count`、四种拒收形态)、flow 动作的 `onSuccess` 读 `${result.output.NAME}`;配三条 eval。新增先在同文件删重复内容付账,余额按裁决 A 提高棘轮里该包五行的天花板。 - **为什么改**:AI 作者在 18.2 上按阶段分组列表、算平均成交时长、写带记录名的 toast、流程跑完后跳到新建记录,技能文档一条都没教(审计 #26184 的 U1 / U2 / U4 / U5 行);其中 `${record.name}` 写进 toast 会在 `os validate` 被拒。 - **风险与回滚**:只改技能 markdown、eval JSON 和 `scripts/check-skills-token-ratchet.mjs` 的五行数字;不发版、无运行时改动,回滚 = revert 本 PR。风险在教学措辞与 spec 是否一致 —— 三条新示例形状都用 zod 解析探针验过(见 Tests)。 - **先看**:① `skills/objectstack-ui/rules/actions.md` 的文案占位一行和 flow 示例的 `onSuccess`;② `rules/list-views.md` 新的 Grouping 节(select 按选项顺序、`orderBy` 只接数字/日期/布尔);③ `scripts/check-skills-token-ratchet.mjs` 五行天花板(+43 / −13 / −12 / +284 / +119)。 - **两分钟验证**:`node scripts/check-skills-token-ratchet.mjs` —— 全绿,objectstack-ui 的 actions / dashboards / list-views 与两个 eval 文件 headroom 都是 0;或 `pnpm --filter @objectstack/spec check:skill-examples` —— 两个改过的 `os:check` 示例编译通过。 - **需要你做的**:Approve(Tier H,`skills/**` 是发布面),席位随后落地。 ## What changes Four rows of the audit on #26184 (comment 6425678185, § objectstack-ui: U1, U2, U4, U5), taught in the fewest lines an AI author needs, in the files the card names: - `rules/list-views.md` — a **Grouping** section (and its TOC entry): `grouping.fields[]` nests levels outermost first; a `select` level keeps its declared option order, so "stage in pipeline order" is `{ field: 'stage' }` with the stages declared in that order; `orderBy` places a level's groups by a number, date or boolean field of the same object (the period-label case), and text or a select there is refused (`list-view-group-order-unorderable`). - `rules/dashboards.md` — the `duration` measure, in place: the Level A table's measures row names it, the **Computed column** bullet routes "time between two `datetime` fields of a record" to `{ aggregate: 'avg', duration: { from, to, unit } }` instead of a hook-kept number, and the leave-days example says why it still escalates (`date` fields, so no `duration`). No parallel section. - `rules/actions.md` — one bullet on the copy slots: holes are `{{ }}`, `confirmText` reads `record`, `successMessage` reads `record` and `result`, `errorMessage` fills none, a `bulkActionDefs` entry's `confirmText` reads `{{ count }}` and its params by bare name, and `${record.name}`, `{name}`, logic in a hole and a blank sentence fail `os validate`. The reassign example's toast now carries a hole (`'Lead {{ record.name }} reassigned.'`). The flow example gains `onSuccess: { navigate: '/apps/crm/opportunity/record/${result.output.opportunity_id}' }` and its heading says when the hop runs (a completed run, not one paused at a screen) and what `${result.output.NAME}` reads (an `isOutput` variable). - `evals/views-apps-actions-pages.json` — eval 7 (group opportunities by stage in pipeline order, forecasts by `period_label` newest first) and eval 8 ("Lead NAME reassigned" toast + open the opportunity a flow created). `evals/analytics-inline-vs-dataset.json` — eval 8 (average days from `created_at` to `closed_at`). Same shape as the files' existing entries. - `scripts/check-skills-token-ratchet.mjs` — the five `CEILINGS` rows of the files above, moved to the measured counts (the ruling and arithmetic below). **Line readings** (whole file, before → after, on base 756220c): | file | lines | tokens (`ceil(utf8 bytes / 4)`, the sibling gate's unit) | |:--|:--|:--| | `rules/actions.md` | 206 → 203 | 2101 → 2151 (+50) | | `rules/dashboards.md` | 465 → 464 | 6251 → 6239 (−12) | | `rules/list-views.md` | 303 → 311 | 3133 → 3129 (−4) | | **package** (`SKILL.md` + `references/**/*.md` + `rules/**/*.md`) | **2053 → 2057** | **28253 → 28287 (+34)** | | `evals/views-apps-actions-pages.json` | 65 → 85 | 1505 → 1789 (+284) | | `evals/analytics-inline-vs-dataset.json` | 77 → 87 | 1229 → 1348 (+119) | | whole `skills/objectstack-ui/` tree | 2226 → 2260 | ratchet bundle total 206107 → 206544 (+437) | **Paid down first, then raised — the token ratchet.** `node scripts/check-skills-token-ratchet.mjs` prices every file above per file, shrink-only, and on base every one of them sat at 0–8 tokens of headroom. The teaching alone costs +141 / +66 / +127 / +284 / +119. Each rule file pays in its own file by deleting restatements, every cut's surviving home named here: - `actions.md` (−91): the sentence that `ActionRunner.executeUrl` reads `openIn` with priority over a legacy heuristic (an implementation note; the author-facing rule is the paragraph it sat in); the "validated today, rendered once the console's print rendering ships" clause (home: `rules/pages.md` → Print pages, which carries that status); the params-callout's closing remedy that re-listed the two mechanisms (home: the two subsections directly above it; the callout now points there); "so it matches hooks and seed data with zero relearning" (home: the same paragraph's last sentence, "same blessed name … as hooks", and the code comment "identical to the hook surface"); the code comment "zero-roundtrip; endpoint self-auths" (home: the prose two lines above the block). Plus the card-named stale line 21 reworded ("author-controlled" dropped). Net +50; the ceiling absorbs its 7 of headroom and moves by the remaining **+43**. - `dashboards.md` (−78): the four-line comment inside the `SalesDashboard` example restating that widgets bind a dataset and select by name and that `filter` is the runtime filter (home: the **Dataset-Bound Widgets** section, which states all three). Net −12; the ceiling is **lowered** to the measurement. - `list-views.md` (−131): the paragraph after the allowed-set table that restated its two rows (home: the table, which the sentence above it already calls "the whole rule"); the related-record search paragraph, compressed to one sentence that points at **objectstack-data → Search Fields**, which carries the mirror field, the two hooks and why a `formula` cannot be the mirror in full (the dotted-path refusal stays in this file's own table row). Net −4; the ceiling is **lowered** to the measurement. - The two eval files are fixtures with nothing to delete; their rows are raised by the whole addition (**+284**, **+119**). The ruling the raise runs on — Warren, 2026-10-27T01:21Z, option A tapped on the decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」, verbatim and untranslated: **「A:先削重复,余额按实测提高」**. Each row's comment in the script quotes it. Ceilings net +421 (+437 of growth, 16 of pre-existing headroom absorbed); every touched row is pinned at its measurement, zero headroom. ## Why An AI author building lists, dashboards and actions on 18.2 could not: group a list by stage in pipeline order (list-view `grouping` was untaught — 0 hits); keep "average time to close" on the dataset (the escalation advice sent every computed value to a hook-kept stored field, and never named the measure that now exists); write a toast that names the record (`'Lead ${record.name} reassigned'` reads as plain copy in the old text and is refused at `os validate`); or take the user to the record a flow created (`onSuccess` is read for `type: 'flow'` since the executor set widened, and the flow example lacked it). **Premise checks against `origin/main` 756220c** (the order's hypotheses; the cited commits are unreachable in this shallow clone too — `git cat-file -t` answers missing for all nine — so the spec lines are the evidence): 1. *Holds, with one correction to the example.* `grouping.fields[].orderBy` is `packages/spec/src/ui/view.zod.ts:1441`. But a `select` level already groups in its declared option order — the grid's group comparator ranks option values by the field's `options` (`ui/packages/plugin-grid/src/useGroupedData.ts`, `compareGroupEntries`; `ObjectGrid.tsx` builds `groupOptionRanks`), and `orderBy` over a select is refused by `list-view-group-order-unorderable` (`packages/lint/src/validate-list-view-field-refs.ts:405`; `rule-explanations.ts:3417`: min/max over the string classes is refused by `AGGREGATE_FIELD_TYPE_COMPATIBILITY`). So the pipeline-order case is taught as `{ field: 'stage' }` with no `orderBy`, and the `orderBy` example is the spec's own fixture case, `period_label` ordered by `period_start` (`view-grouping-query.test.ts:595`). Eval 7 expects exactly that split. 2. *Holds.* `DatasetMeasureDurationSchema` at `packages/spec/src/ui/dataset.zod.ts:276` (`from`, `to`, `unit` ∈ seconds/minutes/hours/days, aggregates avg/min/max/sum, `datetime` columns only — `DATASET_DURATION_FIELD_TYPE`, line 247). Corrected in place at the Computed-column bullet; the leave-days example at lines 93–99 stays right because its fields are `date`. 3. *Holds.* Roots and refusals per `packages/spec/src/ui/action-copy-template.ts` (`actionConfirmTemplateRoots`, `ACTION_SUCCESS_TEMPLATE_ROOTS`, `ACTION_ERROR_TEMPLATE_ROOTS`); bulk roots `count` + params by bare name per `bulk-action.zod.ts:334–364`. One nuance kept out of the teaching: a registered action's `errorMessage` is an `I18nLabel`, not a template slot (`action.zod.ts:1424`), and `validate-action-copy-templates.ts` judges `errorMessage` only on page action rows — "fills none" is true on both surfaces. 4. *Holds.* `onSuccess` beside `type: 'flow'` (`action-on-success-executor.ts:25–33`; `action.zod.ts:1719–1721`), `${result.output.VAR}` reading the flow's `isOutput` variables; the example's `opportunity_id` is declared `isOutput: true` by `examples/app-crm/src/flows/convert-lead.flow.ts:47`. 5. *As predicted:* spec lines cited, not commits. 6. *Holds:* 0 hits for `orderBy` / `duration` / `onSuccess` in the package's evals on base; all three evals are new. ## Tests No TypeScript changes; the gate that compiles the package's `os:check` examples and a zod parse probe of every new shape are the tests. - **Zod parse probe** (`tsx`, against `packages/spec/src` on this head; script in the dev's scratchpad, nothing committed): - `ActionSchema.safeParse` of the reassign example (`successMessage: 'Lead {{ record.name }} reassigned.'`) → PARSES; of the convert example with `onSuccess.navigate` → PARSES; control, `onSuccess` on a `type: 'url'` action → REFUSED ("a `url` action is itself the navigation…"). - `closedScopeTemplateRefusal` over the `successMessage` roots: `'Lead ${record.name} reassigned'` → refused ("write each `${…}` token as the `{{ }}` hole it names"); `'Lead {name} reassigned'` → refused (single-brace dialect); `'Lead {{ record.name }} reassigned'` → no refusal. - `DatasetMeasureSchema.safeParse({ name: 'avg_days_to_close', aggregate: 'avg', duration: { from: 'created_at', to: 'closed_at', unit: 'days' } })` → PARSES; controls: `aggregate: 'count'` with `duration` → REFUSED ("a duration measure takes `aggregate` avg / min / max / sum"); `field` beside `duration` → REFUSED ("it takes no `field`"). - `GroupingFieldSchema.safeParse({ field: 'stage' })` → PARSES; `{ field: 'period_label', orderBy: 'period_start', order: 'desc' }` → PARSES; control, padded `orderBy: ' period_start'` → REFUSED (the non-padded rule). - Both eval files load with `json.load` (shape unchanged: `id` / `prompt` / `expected_output` / `files` / `assertions`). - `pnpm exec eslint --no-inline-config scripts/check-skills-token-ratchet.mjs` → exit 0. - Ablation: n/a — no tested behaviour changed (docs, fixtures and a gate's pinned numbers). ## Acceptance notes - The card's acceptance — an eval per new teaching; `check:skill-docs`, `check:skill-refs`, `check:skill-examples` green; both readings reported; additions paid inside the package; nothing removed beyond the stale lines the card names — is met as read above, with one widening the ruling ordered: the five `CEILINGS` rows in `scripts/check-skills-token-ratchet.mjs`, outside `skills/objectstack-ui/**`. The seat amends the claim's file surface for them. - The cuts delete restatements only; each has a surviving home named above. The card-named stale lines (`actions.md:20-21`, `:64-69`; `dashboards.md:69-72`, `:90-92`) are rewritten in place, not dropped. - `references/_index.md` is generator-owned and unchanged (`check:skill-refs` green); line 1 of `SKILL.md` is untouched (PR #25640 owns it). - Observed, not filed (os-dev rule 3, none of the three classes): `rules/actions.md` still says `errorMessage` is "toast copy" for a registered action, which is right; that a page action row's `errorMessage` is additionally judged as a template slot is an objectui-surface nuance with no author-facing choice. `evals/README.md` describes the two files generically; no edit needed. - `skip-changeset`: the skills packages carry no `package.json` and nothing in a released package's `files[]` moves; the ratchet script is repo tooling. ## Verification Gates derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this tree (50 commands: 42 matched by path, 8 declared whole-tree), every exit code captured before any pipe; reconciled with `--ran`. **48 green, 2 NOT MEASURED (exit 3, prerequisite: a whole-tree build), 0 red, 0 unrun** on head {HEAD}. <details><summary>Card acceptance gates and the ratchet (exit codes captured before any pipe)</summary> - `pnpm --filter @objectstack/spec check:skill-docs` → exit 0 - `pnpm --filter @objectstack/spec check:skill-refs` → exit 0 (`references/_index.md` in sync, unchanged) - `pnpm --filter @objectstack/spec check:skill-examples` → exit 0 — 264 marked examples type-check across 3 surfaces (230 skills + docs blocks, the two edited `os:check` blocks in `rules/actions.md` among them), after building `@objectstack/client-react` / `@objectstack/client` under the lock - `node scripts/check-skills-token-ratchet.mjs --self-test` → exit 0 (79 cases); `node scripts/check-skills-token-ratchet.mjs` → exit 0 — every objectstack-ui row green, the five touched files at headroom 0, bundle total 206544 </details> <details><summary>Every other derived family, by exit code</summary> - `node scripts/check-ci-filter-parity.mjs` → exit 0 - `node scripts/check-closing-keyword-parity.mjs` → exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` → exit 0 - `node scripts/check-comment-mask-corpus.mjs` → exit 0 - `node scripts/check-declaration-mirrors.mjs` → exit 0 - `node scripts/check-declaration-mirrors.mjs --self-test` → exit 0 - `node scripts/check-doc-route-spelling.mjs --advisory` → exit 0 - `node scripts/check-doc-route-spelling.mjs --self-test` → exit 0 - `node scripts/check-dts-emitted.mjs --self-test` → exit 0 - `node scripts/check-scripts-symbol-anchors.mjs` → exit 0 - `node scripts/check-scripts-symbol-anchors.mjs --self-test` → exit 0 - `node scripts/check-self-test-wired.mjs` → exit 0 - `node scripts/check-self-test-wired.mjs --self-test` → exit 0 - `node scripts/check-self-test-workflow-commands.mjs` → exit 0 - `node scripts/check-self-test-workflow-commands.mjs --self-test` → exit 0 - `node scripts/check-whole-set-label-write.mjs` → exit 0 - `node scripts/check-whole-set-label-write.mjs --self-test` → exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` → exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` → exit 0 - `node scripts/pm/bare-root-worklist.mjs --self-test` → exit 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` → exit 0 - `pnpm check:agent-test-spelling` → exit 0 - `pnpm check:bash32-floor` → exit 0 - `pnpm check:cli-command-ids` → exit 0 - `pnpm check:corpus-claim-drift` → exit 0 - `pnpm check:cross-package-test-inputs` → exit 0 - `pnpm check:doc-authoring` → exit 0 - `pnpm check:driver-memory-census` → exit 0 - `pnpm check:dts-closure` → exit 0 - `pnpm check:entry-guard` → exit 0 - `pnpm check:gitlink-declared` → exit 0 - `pnpm check:nul-bytes` → exit 0 - `pnpm check:parse-guard` → exit 0 - `pnpm check:pm-dispatch-gates` → exit 0 - `pnpm check:pm-governed-merges` → exit 0 - `pnpm check:pm-skill-id-lint` → exit 0 - `pnpm check:pnpm-filter-targets` → exit 0 - `pnpm check:pnpm-flag-spellings` → exit 0 - `pnpm check:ratchet-remedy-authority` → exit 0 - `pnpm check:refd-timer-probe` → exit 0 - `pnpm check:role-word` → exit 0 - `pnpm check:skill-compatibility` → exit 0 - `pnpm check:skill-frame-sync` → exit 0 - `pnpm check:skill-identifier-liveness` → exit 0 - `pnpm check:sourcemap-no-sources-content` → exit 0 - `pnpm check:watch-hint-literal` → exit 0 NOT MEASURED — the gate refused before measuring (exit 3), declared here, CI runs it: - `pnpm check:dual-build-cjs-loads` → exit 3 — reads every package's built `dist/` (93 packages); CI's `Lint & Repo Gates` builds the tree first - `pnpm check:lean-entry-closure` → exit 3 — loads built entry points (`@objectstack/objectql/core` absent here); same CI family </details> <details><summary>Reconciliation, builds and the rest</summary> - `node scripts/pm/dispatch-gates.mjs --ran gates/ran.list` → exit 0 — ✓ dispatch-gates --ran: 50 derived famil(ies) accounted for — 48 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3). - Builds under `scripts/pm/os-verify-lock.sh` (slot `issue-26295`): `pnpm --filter @objectstack/spec build` VERDICT command-exit 0 (64 s held); `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` exit 0; `turbo run build --filter=@objectstack/client-react --filter=@objectstack/client` VERDICT command-exit 0 (133 s held). - `pnpm check:pm-dispatch-gates` → exit 0 (fast tier, 1901 cases), run detached as os-dev directs. - Not owed locally: no package `test` / `typecheck` (no TypeScript changed; `dispatch-gates` derives no type-check program for this diff); `test:repo`, `pnpm lint` and the whole-tree scans are CI's. Dogfood battery: n/a — no `!` in the title and no `(narrowing)` arm. - CI on the PR: not waited for (the order forbids it); the seat reads the check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXGPgrhvw7MBugmme8)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c3097e commit 591a796

6 files changed

Lines changed: 100 additions & 37 deletions

File tree

‎scripts/check-skills-token-ratchet.mjs‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,12 @@ export const CEILINGS = new Map([
611611
// deleting one from this file. Independently reproducible without running
612612
// this script:
613613
// ceil($(wc -c < skills/objectstack-ui/evals/analytics-inline-vs-dataset.json) / 4) == ceil(4915 / 4)
614-
['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1229],
614+
// 1229 -> 1348 (#26295): one eval prompt for the `duration` measure, +119 tokens in the
615+
// file's shape. An eval file is fixtures — nothing in it restates anything, so there was
616+
// nothing to delete to fund it; the whole addition is raised, pinned AT the measurement.
617+
// Maintainer ruling 2026-10-27 (Warren, decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」,
618+
// option A tapped at 01:21Z), verbatim and untranslated: 「A:先削重复,余额按实测提高」.
619+
['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1348],
615620

616621
// objectstack-ui `rules/` — the five files the #14296 item 1 split created.
617622
// Each row is an INITIAL measurement AT its landed count, so every one starts
@@ -621,19 +626,39 @@ export const CEILINGS = new Map([
621626
// the reassembly exposed (plus three cross-references the move killed) and
622627
// added nothing but five titles and five pointers,
623628
// so the package nets -58 (33,957 -> 33,899).
624-
['skills/objectstack-ui/rules/actions.md', 2108],
629+
// 2108 -> 2151 (#26295): the 18.2 teaching an AI author of actions was missing — the
630+
// `{{ }}` copy holes with their roots and refused shapes, and a flow action's `onSuccess`
631+
// reading `${result.output.NAME}` — is +141 tokens. PAID DOWN FIRST by 91 tokens of genuine
632+
// deletion in this file (five restatements of this file's own prose: the `openIn`
633+
// priority sentence, the print-status clause that pages.md's Print pages carries, the
634+
// params-callout remedy that restates the two mechanisms above it, the hooks-parity
635+
// clause the paragraph's last sentence already states, one code comment restating the
636+
// prose above it), then the row's 7 tokens of headroom absorb 7; the ceiling moves by the
637+
// remaining 43 and is pinned AT the measurement — zero headroom. Maintainer ruling 2026-10-27 (Warren, decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」,
638+
// option A tapped at 01:21Z), verbatim and untranslated: 「A:先削重复,余额按实测提高」.
639+
['skills/objectstack-ui/rules/actions.md', 2151],
625640
// 6090 -> 6252 (#19715, card item 4): a 5-line table of contents at the top,
626641
// anchors to the file's existing headings, nothing else changed. +162 tokens,
627642
// zero headroom to absorb any of it, ceiling +162. Maintainer ruling
628643
// 2026-09-22 on decision batch #213 item 1, verbatim and untranslated:
629644
// 「1 2 4 同意」.
630-
['skills/objectstack-ui/rules/dashboards.md', 6252],
645+
// 6252 -> 6239 (#26295): LOWERED. The `duration` measure (+66 tokens, the time between two
646+
// `datetime` fields of a record, which the escalation advice sent to a hook-kept number)
647+
// is paid in full by deleting 78 tokens of restatement — the example's four-line comment
648+
// that restated the Dataset-Bound Widgets section — so the file nets -12 and the ceiling
649+
// follows it down to the new measurement, the ratchet's ordinary direction.
650+
['skills/objectstack-ui/rules/dashboards.md', 6239],
631651
// 3011 -> 3141 (#19715, card item 4): a 5-line table of contents at the top,
632652
// anchors to the file's existing headings, nothing else changed. +132 tokens,
633653
// of which the row's 2 tokens of headroom absorb 2; the CEILING moves 130.
634654
// Maintainer ruling 2026-09-22 on decision batch #213 item 1, verbatim and
635655
// untranslated: 「1 2 4 同意」.
636-
['skills/objectstack-ui/rules/list-views.md', 3141],
656+
// 3141 -> 3129 (#26295): LOWERED. List-view `grouping.fields[]` and `orderBy` (+127 tokens;
657+
// grouping was untaught, 0 hits) are paid in full by deleting 131 tokens of restatement —
658+
// the paragraph restating the allowed-set table above it, and the related-record search
659+
// prescription that objectstack-data → Search Fields carries whole — so the file nets -4
660+
// and the ceiling follows it down to the new measurement.
661+
['skills/objectstack-ui/rules/list-views.md', 3129],
637662
['skills/objectstack-ui/rules/navigation.md', 2273],
638663
// 5501 -> 5692 (#19715, card item 4): a 5-line table of contents at the top,
639664
// anchors to the file's existing headings, nothing else changed. +191 tokens,
@@ -691,7 +716,11 @@ export const CEILINGS = new Map([
691716
['skills/objectstack-i18n/evals/bundles-locales-coverage.json', 1004],
692717
['skills/objectstack-platform/evals/config-plugins-ops.json', 1211],
693718
['skills/objectstack-query/evals/filters-pagination-search.json', 955],
694-
['skills/objectstack-ui/evals/views-apps-actions-pages.json', 1505],
719+
// 1505 -> 1789 (#26295): two eval prompts — list grouping with `orderBy`, and the copy hole
720+
// + `onSuccess` pair — +284 tokens in the file's shape; fixtures, nothing to delete, the
721+
// whole addition raised and pinned AT the measurement. Maintainer ruling 2026-10-27 (Warren, decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」,
722+
// option A tapped at 01:21Z), verbatim and untranslated: 「A:先削重复,余额按实测提高」.
723+
['skills/objectstack-ui/evals/views-apps-actions-pages.json', 1789],
695724
['skills/objectstack-upgrade/evals/protocol-major-upgrade.json', 1033],
696725

697726
// the remaining skills' eval notes

‎skills/objectstack-ui/evals/analytics-inline-vs-dataset.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@
7272
"must_contain": ["$contains"],
7373
"must_not_contain": ["field: 'tags'", "groupBy: 'tags'", "sql: 'tags'"]
7474
}
75+
},
76+
{
77+
"id": 8,
78+
"prompt": "Add a KPI of the average days from an opportunity's created_at to its closed_at (both datetime). Emit the metadata.",
79+
"expected_output": "A dataset measure `{ aggregate: 'avg', duration: { from: 'created_at', to: 'closed_at', unit: 'days' } }`; no hook-kept stored number.",
80+
"files": [],
81+
"assertions": {
82+
"must_contain": ["duration", "unit: 'days'"],
83+
"must_not_contain": ["beforeUpdate"]
84+
}
7585
}
7686
]
7787
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@
6060
"must_contain": ["type: 'wizard'", "inlineEdit", "formViews", "master_detail"],
6161
"must_not_contain": ["type: 'screen'"]
6262
}
63+
},
64+
{
65+
"id": 7,
66+
"prompt": "Group the opportunity list by stage in pipeline order, and the forecast list by period_label, newest first (period_start is a date).",
67+
"expected_output": "`grouping: { fields: [{ field: 'stage' }] }`: a select keeps its option order, and `orderBy` refuses a select. Forecast: `{ field: 'period_label', orderBy: 'period_start', order: 'desc' }`.",
68+
"files": [],
69+
"assertions": {
70+
"must_contain": ["grouping", "orderBy: 'period_start'"],
71+
"must_not_contain": ["orderBy: 'stage'"]
72+
}
73+
},
74+
{
75+
"id": 8,
76+
"prompt": "Make the Reassign Lead toast read 'Lead NAME reassigned'; after Convert's lead_conversion flow completes, open the opportunity it created.",
77+
"expected_output": "`successMessage: 'Lead {{ record.name }} reassigned'` (`${record.name}` fails `os validate`); Convert gets `onSuccess: { navigate }` ending `${result.output.opportunity_id}`, an `isOutput` flow variable.",
78+
"files": [],
79+
"assertions": {
80+
"must_contain": ["{{", "onSuccess", "result.output"],
81+
"must_not_contain": ["${record.name}"]
82+
}
6383
}
6484
]
6585
}

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

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
- `visible` — CEL predicate (prefer the `P\`...\`` tagged template); 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.
2020
- `confirmText` — set for any destructive or irreversible operation.
21-
- `successMessage` / `errorMessage` — author-controlled toast copy on success / failure. Always set `successMessage` for non-obvious outcomes; without it the UI shows a generic "Action completed" toast.
21+
- `successMessage` / `errorMessage` — toast copy on success / failure. Always set `successMessage` for non-obvious outcomes; without it the UI shows a generic "Action completed" toast.
22+
- 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`.
2223
- `undoable: true` — an **Undo** in the success toast (and `Ctrl+Z`). Snapshot: runtime on `operation: 'update'`, console on `type: 'api'`; else refused.
2324

2425
Predicates are **bare CEL** — `record.status == "converted"`, evaluated against
@@ -42,14 +43,14 @@ export const ReassignLeadAction = defineAction({
4243
disabled: P`record.status == "converted"`,
4344
params: [{ field: 'assigned_to', required: true }],
4445
undoable: true, // success toast offers Undo; Ctrl+Z works too
45-
successMessage: 'Lead reassigned.',
46+
successMessage: 'Lead {{ record.name }} reassigned.',
4647
errorMessage: "Couldn't reassign this lead — try again.",
4748
});
4849
```
4950

5051
## Examples
5152

52-
**Flow-typed action** (delegates to a screen flow):
53+
**Flow-typed action** (`onSuccess` hops once the run completes, not when it pauses at a screen; its URL template reads an `isOutput` variable as `${result.output.NAME}`):
5354

5455
<!-- os:check -->
5556
```typescript
@@ -67,6 +68,7 @@ export const ConvertLeadAction = defineAction({
6768
visible: P`record.status == "qualified" && record.is_converted == false`,
6869
confirmText: 'Are you sure you want to convert this lead?',
6970
successMessage: 'Lead converted successfully!',
71+
onSuccess: { navigate: '/apps/crm/opportunity/record/${result.output.opportunity_id}' },
7072
refreshAfter: true,
7173
});
7274
```
@@ -114,8 +116,7 @@ A server-side action `body` (and a registered function `handler`) receives a
114116
`recordId` is in scope), `api` (scoped cross-object CRUD), and the caller
115117
identity. Read the caller's active organization under the **blessed**
116118
`organizationId` name — the same value as the `organization_id` column and
117-
`current_user.organizationId` in RLS, so it matches hooks and seed data with
118-
zero relearning:
119+
`current_user.organizationId` in RLS:
119120

120121
```typescript
121122
// ✅ Blessed — identical to the hook surface (ctx.user / ctx.session)
@@ -143,8 +144,7 @@ When you have a **static** `target` URL (relative or absolute) you just want
143144
opened in a new tab, set `openIn: 'new-tab'` on a `type: 'url'` action. No
144145
handler, no synchronous pre-open. `openIn: 'self'` forces in-place navigation;
145146
omit it and external/absolute URLs open in a new tab while relative URLs
146-
navigate in place. objectui's `ActionRunner.executeUrl` reads `openIn` with
147-
priority over the legacy heuristic.
147+
navigate in place.
148148

149149
<!-- os:check -->
150150
```typescript
@@ -179,21 +179,18 @@ export const OpenInvoicePdfAction = defineAction({
179179
type: 'script',
180180
target: 'open_invoice_pdf', // registered handler; skipped while newTabUrl is set
181181
opensInNewTab: true,
182-
newTabUrl: '/api/v1/invoice/{recordId}/pdf', // zero-roundtrip; endpoint self-auths
182+
newTabUrl: '/api/v1/invoice/{recordId}/pdf',
183183
locations: ['record_header'],
184184
});
185185
```
186186

187-
> A printable document (an invoice) is a page that declares `print` — [Print pages](./pages.md#print-pages):
188-
> validated today, rendered once the console's print rendering ships.
187+
> A printable document (an invoice) is a page that declares `print` — [Print pages](./pages.md#print-pages).
189188
190189
> ⚠️ **Never express new-tab behavior via `params`.** `params` is exclusively
191190
> `ActionParam[]` for collecting **user input**. Writing an object form like
192191
> `params: { newTab: true }` fails the zod build outright; the array form
193192
> `params: [{ name: 'newTab', type: 'checkbox' }]` *builds* but mis-renders as a
194-
> user-facing checkbox in the param-collection dialog. Use `openIn` (static) or
195-
> `opensInNewTab`/`newTabUrl` (async) instead — these are static execution
196-
> options, not inputs.
193+
> user-facing checkbox in the param-collection dialog. Use one of the two mechanisms above.
197194
198195
## Action Parameter Patterns
199196

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

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ escalate to. Decide on **expressibility**; reuse/governance is Level B.
6767
|:--|:--|
6868
| one base object + **to-one** joins (`include`, ≤3 hops) | a join that **changes grain** / a **to-many** rollup onto the parent |
6969
| 0..N dimensions; date-bucket `day/week/month/quarter/year` | a **computed dimension** / CASE bucket / numeric bin |
70-
| measures `count/sum/avg/min/max/count_distinct` | list aggregation (collect-into-array / concatenate — retired in protocol 17, no spelling exists) |
70+
| measures `count/sum/avg/min/max/count_distinct`, `duration` | list aggregation (collect-into-array / concatenate — retired in protocol 17, no spelling exists) |
7171
| **derived measures** — `ratio/sum/difference/product` of other measures | scalar math on raw fields (`amount*0.8`), aggregate-of-aggregate |
7272
| WHERE (`$and/$or/$not` on the base object) + measure-scoped filters | **HAVING** (filtering the aggregate result) |
7373
| `compareTo` (previous period/year) + `totals` (matrix subtotals) | **window** (rank, running total, lag/lead, %-of-total); **union**; reshaping params |
@@ -88,15 +88,18 @@ Standardized answers to the recurring ambiguous cases:
8888
- **To-one enrichment** ("revenue by `account.industry`") keeps grain — `account`
8989
in `include`, `account.industry` as a dimension.
9090
- **Computed column.** Format/currency → the measure's `format`/`currency`;
91-
arithmetic over measures → a **derived measure**; CASE / bins /
91+
arithmetic over measures → a **derived measure**; time between two
92+
`datetime` fields of a record → a **`duration` measure**, not a hook-kept
93+
number (`{ name: 'avg_days_to_close', aggregate: 'avg', duration: { from:
94+
'created_at', to: 'closed_at', unit: 'days' } }`); CASE / bins /
9295
`revenue*0.8` / computed dimensions → **not** a dataset.
9396
- **Total over a computed value.** `sum`/`avg`/`min`/`max` need a STORED numeric
9497
or boolean field (`percent` not under `sum`; `min`/`max` also dates); a `formula`
9598
has no column: refused whatever its `returnType` says. Store the number
9699
(user-entered, a `summary` rollup, or hook-kept) or title the widget the `count`
97100
it is. Leave days: `sum` over the `formula` `days_requested` (`end - start`) is
98-
refused ⇒ a stored `number` `days_count` a `beforeInsert`/`beforeUpdate` hook
99-
writes, then `{ name: 'days_sum', aggregate: 'sum', field: 'days_count' }`.
101+
refused (`date` fields: no `duration`) ⇒ a stored `number` `days_count` a
102+
`beforeInsert`/`beforeUpdate` hook writes, then `{ name: 'days_sum', aggregate: 'sum', field: 'days_count' }`.
100103
- **Breakdown by a multi-value field.** `multiselect`/`checkboxes`/`tags` and
101104
any `multiple: true` field is refused as a dataset or cube dimension and as
102105
`groupBy` (`INVALID_FIELD`; no per-member buckets). One widget per member,
@@ -168,10 +171,6 @@ export const SalesDashboard: Dashboard = {
168171
optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' } },
169172
],
170173

171-
// ADR-0021: widgets bind a semantic `dataset` and select dimensions/measures
172-
// BY NAME (the `opportunity_metrics` / `order_metrics` datasets define the base
173-
// object, measures, and date dimensions — see Guides → Analytics Datasets). The
174-
// widget `filter` is the presentation-scope runtimeFilter.
175174
widgets: [
176175
{
177176
id: 'total_pipeline_value', type: 'metric',

‎skills/objectstack-ui/rules/list-views.md‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# List, Kanban & Gantt Views
22

33
- [Configuring a List View](#configuring-a-list-view) · [The `defineView` container](#the-defineview-container-viewts-file-shape) · [Data Source](#data-source-data)
4-
- [Columns](#columns) · [Column Features](#column-features) · [Filtering](#filtering) · [Sorting](#sorting)
4+
- [Columns](#columns) · [Column Features](#column-features) · [Filtering](#filtering) · [Sorting](#sorting) · [Grouping](#grouping)
55
- [End-User Quick Filters](#end-user-quick-filters-userfilters-adr-0047) · [Toolbar Search](#toolbar-search-searchablefields-adr-0061)
66
- [Configuring Kanban Views](#configuring-kanban-views)
77
- [Configuring Gantt Views](#configuring-gantt-views)
@@ -190,10 +190,7 @@ export const CaseViews = defineView({
190190
| declares `searchableFields` | **that list, verbatim** — whatever the field types are |
191191
| declares nothing | the auto-default: the name field + the text-like columns (`text` / `email` / `phone` / `url` / `autonumber` / `textarea` / `markdown` / `select` / `status`) |
192192

193-
Field **type** therefore decides only in the second row: where the object
194-
declares the set, a lookup in it is scanned and a `text` column outside it is
195-
refused. Judge every entry against the object's allowed set, never the type
196-
list. Modelling side: **objectstack-data → Search Fields**. Query side
193+
Modelling side: **objectstack-data → Search Fields**. Query side
197194
(`search.fields` over the API): **objectstack-query → Full-Text Search**.
198195

199196
#### ⛔ One bad entry 400s EVERY search on that list
@@ -233,12 +230,8 @@ export const AuditViews = defineView({
233230
});
234231
```
235232

236-
**Searching by a related record's title:** never reach for a dotted path. The
237-
search axis resolves no traversal, so `account_id.name` is refused rather than
238-
silently dropped. Mirror the parent's title into a **stored** field on this
239-
object and narrow to that. The prescription — the mirror field, the two hooks
240-
that maintain it, and why a `formula` field cannot be the mirror — lives in
241-
**objectstack-data → Search Fields (`searchableFields`)**.
233+
**Searching by a related record's title:** mirror it into a **stored** field
234+
and narrow to that — **objectstack-data → Search Fields (`searchableFields`)**.
242235

243236
### Sorting
244237

@@ -250,6 +243,21 @@ sort: [
250243
]
251244
```
252245

246+
### Grouping
247+
248+
`grouping.fields[]` nests levels, outermost first. A `select` level keeps its
249+
option order (declare stages in pipeline order); others sort by label.
250+
`orderBy` orders a level's groups by a number, date or boolean field of the
251+
same object instead — text or a select is refused
252+
(`list-view-group-order-unorderable`):
253+
254+
```typescript
255+
grouping: { fields: [
256+
{ field: 'stage' },
257+
{ field: 'period_label', orderBy: 'period_start', order: 'desc' }, // newest first
258+
] },
259+
```
260+
253261
---
254262

255263
## Configuring Kanban Views

0 commit comments

Comments
 (0)