Skip to content

Commit c183a12

Browse files
feat(spec)!: promote userFilters.allowAddTab,随后收紧 UserFiltersSchema (#5073) (#5237)
* feat(spec)!: promote `userFilters.allowAddTab`, then close UserFiltersSchema (#5073) `UserFiltersSchema` was the last shape #4001 批 18 left open in `ui/view.zod.ts` whose blocker was a capability question rather than a strictness one. objectui's renderer reads `config.allowAddTab` and renders an add-tab affordance from it (`plugin-list/src/UserFilters.tsx:182`/`:742`), and its own schema declared the key — the difference between the two shapes was exactly that one key. Because `saveMetaItem` validates with `safeParse` but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability worked. Closing without declaring it would have 422'd a shipped config with a message naming a key the author was right to write (finding 7), not converted a silent failure into a loud one. Maintainer adjudication (2026-08-04) chose promote-then-close in one PR: - `allowAddTab` is DECLARED, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / AI authors) instead of one React file. The `.describe()` is scoped to what the renderer really does — it renders the affordance; the button carries no click handler today, filed separately, and promising more would be PD#10. - `UserFiltersSchema` closes behind it, in the same change, so there is no state where the key is declared but its neighbours still vanish silently. Closing flips `ObjectUserFiltersSchema` (`.omit()`ed off the base, and `.omit()` inherits posture) from dropping the page-only keys to rejecting them — 批 6e's question, and the wanted answer: the CLI lint already reported them, so the two doors now agree. But `.omit()` also inherits the base's error map, whose `knownKeys` still listed the omitted keys: measured on the flip, `tab` was answered "Did you mean `tab` -> `tabs`?", steering the author at the one key that surface refuses. The object variant now builds its map over the OMITTED shape (shape still derived by `.omit()`, so #2231 holds) with `guidance` pointing all three page-only keys at `listViews`. Ledger: view.zod.ts Class 6 -> 5 authorable; counts regenerated via `gen:strictness-ledger` (ui/ strict 117 -> 119, strip 76 -> 75, sites 50 -> 51). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 * build(spec): regenerate strictness counts from the merged tree (#5073 + #5221) The merge with origin/main brought in #5221 (`chart.zod.ts` gains `ChartDrillDownSchema`). Git text-merged the GENERATED counts file cleanly and WRONGLY — the ledger's own documented failure mode: each side's row deltas do not overlap, so the subtotals merge clean while naming a tree neither branch ever had (483/199, missing #5221's site). Regenerated via `gen:strictness-ledger` from the merged tree, which is the only state in which the numbers are right: 484 sites / ui 200, strict 244 / ui 120. Both hand-written prose rows survive untouched — `view.zod.ts` (this PR) and `chart.zod.ts` (#5221) are different rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 * docs(spec): name the renderer-gap issue (#5236) in the JSDoc and ledger row Both spots described the add-tab button's missing click handler as "filed separately" without the number, which is the shape AGENTS.md #13 warns about — a decision nobody can find. Now the reader of either surface can reach the evidence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c142ced commit c183a12

11 files changed

Lines changed: 284 additions & 100 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': major
3+
---
4+
5+
spec(ui): `userFilters.allowAddTab` 提升进契约,`UserFiltersSchema` 随之收紧 (#5073)
6+
7+
`UserFiltersSchema` 是 #4001 批 18 在 `ui/view.zod.ts` 留下的最后一块「不是 strictness 问题」的开放形状 —— 挡住它的是一条**能力声明**,不是姿态判断。
8+
9+
**为什么之前不能直接关。** objectui 的列表渲染器真读 `config.allowAddTab` 并据此渲染「新增 tab」控件(`plugin-list/src/UserFilters.tsx:182` / `:742`),它自己的 `UserFiltersSchema` 也声明了这个键 —— 两边形状的差集恰好只有这一个。而 `saveMetaItem` 用 `safeParse` 校验后**原样存原始 body**(丢弃 `parsed.data`,好让 Studio 的辅助键活过往返),所以被 strip 掉的只是那份被丢弃的解析结果:存储里键还在,渲染器读得到,**这个能力今天是工作的**。直接收紧不是「把静默失效变响亮」,而是把一个已发布、在用的配置变成 422 —— 而且 422 会点名一个作者本来写对了的键,正是本战役 finding 7 的形状(平台权威把作者引向删掉能工作的东西)。
10+
11+
**裁定与落地(维护者 2026-08-04,选项 A):promote 后收紧,同 PR 完成。** `allowAddTab` 现在**声明**在 `UserFiltersSchema` 上,能力因此可从契约被发现 —— JSON Schema、Studio 的 SchemaForm、AI 作者都看得到,而不是只存在于一个 React 文件里。被否决的是判它为 objectui-only 扩展(`SANCTIONED_LOCAL`):那会让 `packages/spec` 与 objectui 成为同一份契约的两个事实来源,正是 #2231 的 derive-by-reference 统一要消掉的分叉(PD#12)。声明的措辞刻意收窄到渲染器真做的事 —— 它声明「渲染出新增 tab 的入口」,不承诺点击后能创建预设(objectui 那个按钮目前没有 click handler,已另行立案),因为承诺更多就是 PD#10 的「宣传运行时并不交付的能力」。
12+
13+
## BREAKING
14+
15+
**1. `userFilters` 上的未知键从静默丢弃变为拒绝。**
16+
17+
```diff
18+
userFilters: {
19+
element: 'tabs',
20+
- allowAddTabs: true, // 拼错 → 以前静默消失,现在 422(并提示 → allowAddTab)
21+
+ allowAddTab: true,
22+
}
23+
```
24+
25+
FROM → TO:未声明的键 → 删除它,或改成它想表达的那个已声明键。错误信息会点名该键并给出最近的候选。`allowAddTab` 本身**不需要迁移** —— 它现在是合法声明键,原有配置照常通过。
26+
27+
**2. 对象列表视图(`ObjectUserFiltersSchema`)拒绝 page-only 的三个键:`tabs` / `showAllRecords` / `allowAddTab`。**
28+
29+
这三个键在对象视图上一直是无效的(`ObjectUserFiltersSchema` 由 `UserFiltersSchema.omit()` 派生,而 `.omit()` 继承基类姿态),此前被静默丢弃 —— 与此同时 CLI lint(`packages/lint/src/validate-list-view-mode.ts`)早就在报同一个配置。两扇门从此一致。
30+
31+
```diff
32+
// 对象视图:tab 栏的角色已被 ViewTabBar(已存视图切换器)占用
33+
listViews: {
34+
- // userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: '我的' }] }
35+
+ mine: { label: '我的', filter: [['owner', '=', '{userId}']] }, // 每个具名视图渲染成一个分段 tab
36+
}
37+
```
38+
39+
FROM → TO:`userFilters.tabs` → 对象的 `listViews` 具名条目;`showAllRecords` → 默认列表视图本身就是「全部记录」入口;`allowAddTab` → 由 ViewTabBar 自带的新增控件承担。三条拒绝各自带 `guidance` 处方,不是裸的 "unrecognized key"。
40+
41+
派生变体同时改为携带**自己的**错误映射:`.omit()` 会连基类的 `knownKeys` 一起继承,而那份候选列表是从基类形状读的、仍然含被 omit 掉的键 —— 实测在对象视图上写 `tab` 会被答复 *"Did you mean `tab` → `tabs`?"*,把作者指向这个形状唯一拒绝的键。形状仍由 `.omit()` 派生(#2231 不变),候选池改为按 omit 后的形状构建。

‎content/docs/references/ui/page.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Interface-level page configuration (Airtable parity)
5555
| **levels** | `integer` | optional | Number of hierarchy levels to display |
5656
| **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. |
5757
| **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration |
58-
| **userFilters** | `{ element: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean }` | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) |
58+
| **userFilters** | `{ element: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) |
5959
| **userActions** | `{ sort: boolean; search: boolean; filter: boolean; refresh: boolean; … }` | optional | User action toggles |
6060
| **addRecord** | `{ enabled: boolean; position: Enum<'top' \| 'bottom' \| 'both'>; mode: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration |
6161
| **buttons** | `string[]` | optional | Toolbar buttons — names of the source object's actions to surface in the page toolbar |

‎content/docs/references/ui/view.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ List chart view configuration
375375
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
376376
| **searchableFields** | `string[]` | optional | Fields enabled for search |
377377
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
378-
| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields |
378+
| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields |
379379
| **resizable** | `boolean` | optional | Enable column resizing |
380380
| **striped** | `boolean` | optional | Striped row styling |
381381
| **bordered** | `boolean` | optional | Show borders |
@@ -653,6 +653,7 @@ End-user quick-filter configuration (Airtable "User filters" parity)
653653
| **fields** | `{ field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]` | optional | Fields exposed as quick filters (dropdown/toggle elements) |
654654
| **tabs** | `{ name: string; label?: string; icon?: string; view?: string; … }[]` | optional | Named filter presets rendered as tabs (tabs element). Reuses ViewTabSchema |
655655
| **showAllRecords** | `boolean` | optional | Show an "All records" tab before the presets (tabs element) |
656+
| **allowAddTab** | `boolean` | optional | Render an "add tab" affordance after the presets (tabs element). Page lists only — object views use `listViews` for named presets |
656657

657658

658659
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 483 |
25-
| Still-open (strip) sites | 235 |
24+
| Object sites in them | 484 |
25+
| Still-open (strip) sites | 234 |
2626
| Files carrying at least one | 36 |
2727

2828
Remaining strip sites by class:
2929

3030
| Bucket | Sites |
3131
|---|---|
32-
| authorable — the ruling's forced scope | 29 |
32+
| authorable — the ruling's forced scope | 28 |
3333
| unresolved — needs a per-schema verdict | 33 |
3434
| wire / open — out of forced scope | 104 |
3535
| no door — no carrier, ADR-0049 territory | 38 |
@@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
4343

4444
| Dir | Sites | strict | passthrough | catchall | strip |
4545
|---|---|---|---|---|---|
46-
| `ui/` | 199 | 118 | 5 | 0 | 76 |
46+
| `ui/` | 200 | 120 | 5 | 0 | 75 |
4747
| `data/` | 162 | 41 | 1 | 0 | 120 |
4848
| `automation/` | 75 | 49 | 0 | 0 | 26 |
4949
| `security/` | 20 | 7 | 0 | 0 | 13 |
5050
| `studio/` | 27 | 27 | 0 | 0 | 0 |
51-
| **total** | **483** | **242** | **6** | **0** | **235** |
51+
| **total** | **484** | **244** | **6** | **0** | **234** |
5252

5353
## File-level triage — site counts
5454

@@ -79,9 +79,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit
7979
| `sharing.zod.ts` | 2 |
8080
| `theme.zod.ts` | 14 |
8181
| `touch.zod.ts` | 7 |
82-
| `view.zod.ts` | 50 |
82+
| `view.zod.ts` | 51 |
8383
| `widget.zod.ts` | 9 |
84-
| **total** | **199** |
84+
| **total** | **200** |
8585

8686
### `data/` — sites
8787

@@ -161,7 +161,7 @@ over it is here.
161161

162162
### `ui/` — open
163163

164-
**76 strip of 199**, in 13 file(s).
164+
**75 strip of 200**, in 13 file(s).
165165

166166
| File | Strip | Sites |
167167
|---|---|---|
@@ -176,13 +176,13 @@ over it is here.
176176
| `offline.zod.ts` | 3 | 3 |
177177
| `sharing.zod.ts` | 1 | 2 |
178178
| `touch.zod.ts` | 7 | 7 |
179-
| `view.zod.ts` | 6 | 50 |
179+
| `view.zod.ts` | 5 | 51 |
180180
| `widget.zod.ts` | 9 | 9 |
181-
| **total** | **76** | **199** |
181+
| **total** | **75** | **200** |
182182

183183
| Bucket | Sites |
184184
|---|---|
185-
| authorable — the ruling's forced scope | 7 |
185+
| authorable — the ruling's forced scope | 6 |
186186
| unresolved — needs a per-schema verdict | 0 |
187187
| wire / open — out of forced scope | 0 |
188188
| no door — no carrier, ADR-0049 territory | 38 |

‎docs/audits/2026-07-unknown-key-strictness-ledger.md‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎packages/spec/authorable-surface.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7964,6 +7964,7 @@
79647964
"ui/UserFilterField:options",
79657965
"ui/UserFilterField:showCount",
79667966
"ui/UserFilterField:type",
7967+
"ui/UserFilters:allowAddTab",
79677968
"ui/UserFilters:element",
79687969
"ui/UserFilters:fields",
79697970
"ui/UserFilters:showAllRecords",

‎packages/spec/scripts/strictness-ledger.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,12 @@ describe('posture reading, with a red control for each', () => {
230230
// And the claim the ledger rests on: no STRIP site moved. Both postures
231231
// involved are non-strip, so the remaining-strip map — the thing every batch
232232
// is planned against — is untouched by this fix.
233-
expect(countStripSites(at('ui/view.zod.ts'))).toBe(6);
233+
//
234+
// The number itself tracks real batches: 6 when #5072 was written, 5 since
235+
// #5073 closed `UserFiltersSchema`. It is the file's live strip count, not a
236+
// #5072 invariant — what #5072 pins is that ITS OWN change moved no strip
237+
// site, and that still reads correctly against whatever the current count is.
238+
expect(countStripSites(at('ui/view.zod.ts'))).toBe(5);
234239
});
235240

236241
it('lets a chained posture override the idiom in either direction (#5072)', () => {

‎packages/spec/src/ui/object-list-view.test.ts‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,36 @@ describe('ObjectListViewSchema (ADR-0047 "views" mode)', () => {
2323
expect((parsed as { userFilters?: unknown }).userFilters).toMatchObject(uf);
2424
});
2525

26-
it('drops the page-only tabs/showAllRecords keys from a dropdown userFilters', () => {
27-
const parsed = ObjectListViewSchema.parse({
26+
it('REJECTS the page-only tabs/showAllRecords/allowAddTab keys on a dropdown userFilters', () => {
27+
// Flipped from "drops" at #5073, and the flip is the point: until then the
28+
// schema silently discarded these while the CLI lint
29+
// (`packages/lint/src/validate-list-view-mode.ts`) reported them — two
30+
// doors disagreeing about the same config. They now agree.
31+
const r = ObjectListViewSchema.safeParse({
2832
...base,
29-
userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: 'Mine', filter: [] }], showAllRecords: true },
33+
userFilters: {
34+
element: 'dropdown',
35+
tabs: [{ name: 'mine', label: 'Mine', filter: [] }],
36+
showAllRecords: true,
37+
allowAddTab: true,
38+
},
3039
} as never);
31-
const parsedUf = (parsed as { userFilters?: Record<string, unknown> }).userFilters!;
32-
expect(parsedUf).not.toHaveProperty('tabs');
33-
expect(parsedUf).not.toHaveProperty('showAllRecords');
34-
expect(parsedUf.element).toBe('dropdown');
40+
expect(r.success).toBe(false);
41+
const msg = JSON.stringify(r.error?.issues ?? []);
42+
expect(msg).toContain('tabs');
43+
expect(msg).toContain('showAllRecords');
44+
expect(msg).toContain('allowAddTab');
45+
});
46+
47+
it('…and the rejection prescribes `listViews`, the thing an object view actually uses', () => {
48+
// A page-only key has a right answer on an object view, so a bare
49+
// "unrecognized key" would be a correct refusal that still leaves the
50+
// author guessing — the failure mode #5073 was filed to avoid.
51+
const r = ObjectListViewSchema.safeParse({
52+
...base,
53+
userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: 'Mine', filter: [] }] },
54+
} as never);
55+
expect(JSON.stringify(r.error?.issues ?? [])).toContain('listViews');
3556
});
3657

3758
it('rejects a tabs-element userFilters (page-only, would collide with ViewTabBar)', () => {

0 commit comments

Comments
 (0)