Skip to content

Commit f5a7250

Browse files
fix(spec): title the view.columns, view.tabs and view.sort repeater row properties (#19917)
Fixes #17507 Clause-②: no ## What this does Studio renders a `type: 'repeater'` form field as a table. Its column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. None of the 25 row properties of the three view repeaters carried a `title`, so the maker saw raw keys (`field`, `width`, `isDefault`, `order`) in every locale, English included. Each row property now carries a short English `.meta({ title })`, in the convention the already-paid carriers use (`.describe(...)` then `.meta({ title: 'Title Case' })`). Three regions of `packages/spec/src/ui/view.zod.ts` change, and nothing else in that file: | carrier | schema | row properties | titles | |---|---|---|---| | `view.columns` | `ListColumnSchema` | 14 | Field, Label, Width (px), Alignment, Hidden, Sortable, Resizable, Wrap Text, Renderer Type, Pinned, Summary, Prefix, Primary Link, Click Action | | `view.tabs` | `ViewTabSchema` | 9 | Name, Label, Icon, List View, Filter, Display Order, Pinned, Default Tab, Visible | | `view.sort` | the list arm's INLINE `{ field, order }` entry | 2 | Field, Direction (the same titles as the shared `SortItemSchema`) | The repeater-title ledger (`packages/spec/src/kernel/repeater-item-titles.test.ts`) loses its last three entries (`view:columns`, `view:sort`, `view:tabs`) and is now EMPTY. Every repeater a form declares is fully titled, so a new untitled one fails its own PR. The ledger's header comment now says so. `ViewTabSchema` is also reused outside `view.tabs` (for example `json-schema/ui/InterfacePageConfig.json` now carries `"Default Tab"`), so those uses get the same titles. That is expected: it is one schema. ## Measured first, on the base Base: `44ce049a8c`, which was `origin/main` when the worktree was cut. I re-derived the counts through the ledger's own predicate: `z.toJSONSchema(getMetadataTypeSchema('view'), { unrepresentable: 'any', io: 'input' })`, with union arms kept as separate candidates and the property node read before its `$ref` target. The derivation helpers were copied from the ledger file into a scratch script. No source regex was used. | carrier | authorable | untitled before | untitled after | |---|---|---|---| | view:columns | 14 | 14 | 0 | | view:tabs | 9 | 9 | 0 | | view:sort | 2 | 2 | 0 | These are the same 14 / 9 / 2 as the card's 2026-09-10 reading. I re-ran the derivation with `io: 'output'`, which is what `/meta/types` serves. It also reads 0 / 0 / 0 untitled after the change. - **What "title" means here.** The ledger reads `raw?.title ?? prop?.title`: the property node first, then its `$ref` target. That is the JSON Schema `title`, which zod emits from `.meta({ title })`. `.describe()` feeds `description` only. - **What the ledger records.** It records membership only (a `Set` of carrier ids), not a count. A carrier in the set must have a non-empty `untitled` list. A carrier outside the set must have an empty `untitled` list. So "shrink" here means deleting the three ids. ## Both traps, handled 1. **The union arm.** `columns` is an object array on the list arm and an INTEGER on the form arm. The ledger keeps every union arm as a separate candidate and prefers the object-item array arm. The list arm's `z.union([z.array(z.string()), z.array(ListColumnSchema)])` resolves to the `ListColumnSchema` rows. Nothing here merges arms. 2. **The inline sort entry.** `view.sort` is titled on the INLINE entry itself. The shared `SortItemSchema` was titled earlier and never reached this entry. ## Firing control The fix was committed first. I then mutated it and restored it through `scripts/ablation-replace.mjs`: - **Mutation:** `node scripts/ablation-replace.mjs --file packages/spec/src/ui/view.zod.ts --anchor ".meta({ title: 'Display Order' })" --delete -- pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/kernel/repeater-item-titles.test.ts` - on disk: anchor count 1 to 0, blob `b4451833e711` to `a014831e9ce2`. - **RED:** `view:tabs: these row properties have no .meta({ title }) ... expected [ 'order' ] to deeply equal []`. Tests: 1 failed, 25 passed (26). - restored: blob equals HEAD (`b4451833e711`), and `git diff HEAD` is empty. - **Green leg:** the same test file on the restored tree at `7a30204ebf`. Tests: 26 passed (26). - **An earlier attempt was a no-op.** It used `--replacement` with text that is a substring of the anchor. The tool refused it before the command ran (the replacement count rose by 0) and restored the file. It produced no reading. ## Downstream readers: the generators | generator | result | |---|---| | `pnpm --filter @objectstack/spec build` (runs `gen:schema` and `gen:openapi`) | no tracked file moved. The gitignored but published `json-schema/ui/ListColumn.json` now carries the titles (`"title": "Field"`, `"title": "Width (px)"` and the rest). | | `pnpm --filter @objectstack/spec check:generated` | all 15 artifacts up to date: `authorable-surface/` (keys, not titles), `api-surface/` and `content/docs/references/**` are byte-identical | | `pnpm i18n:extract` | 11 files written, zero diff | | `pnpm check:i18n` (all nine bundle packages) | in sync | No generated file moved. The titles do not reach the metadata-form bundles, because `viewForm` enumerates no row children for these three repeaters and `os i18n extract` keys off declared children. See Acceptance notes. ## Changeset `.changeset/17507-view-repeater-row-titles.md` is `@objectstack/spec: patch`. My reading: - **Published bytes move, so `skip-changeset` is wrong.** `@objectstack/spec` publishes `dist`, `json-schema` and `src/**/*.zod.ts` (its `files[]`). After the build, `dist/ui/index.mjs` carries `Renderer Type` (1 hit; 0 hits in `src` at the base; a nonsense string reads 0). - **Nothing an author can write moves.** No key or value is added, removed or narrowed, and `authorable-surface/` is byte-identical. So `Clause-②: no`, and a released-package fix takes `patch` (AGENTS.md Post-Task Checklist step 3). - **The precedent is split.** `#17506`'s PR read the same shape as `patch` with `Clause-②: no`. `#17505`'s PR took `yes` / `minor` for the same shape. This PR follows the claim and the newer reading. ## Verification All runs are at `7a30204ebf` unless stated. Heavy runs went through `scripts/pm/os-verify-lock.sh`, on a shared box. - `pnpm --filter @objectstack/spec build`: green, at `69da9fed7e` (the fix commit, where `check:generated` also ran; the only later commit adds the changeset file) and again at `7a30204ebf`. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: 526 files, 15501 passed, 1 todo. - `pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2`: 35 files, 602 passed. - `pnpm --filter @objectstack/spec typecheck`: green (tsc, the scripts program and the test layer). - `pnpm --filter @objectstack/platform-objects exec vitest run --passWithNoTests --maxWorkers=2`: 54 files, 883 passed. This includes `repeater-row-properties.test.ts`, whose dark control ("view enumerates no repeater row properties yet") still holds. - **Consumers of the served titles** (the wire shape gains `title` nodes): - `@objectstack/metadata-protocol` `protocol.meta-types-degenerate-derivation.test.ts` and `protocol.meta-types-unauthorable-columns.test.ts`: 26 passed. - `@objectstack/rest` `meta-types-schema-titles.test.ts`: 3 passed. - Declared narrowing: the rest of those two packages' suites are left to CI. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` was re-derived after the changeset landed: 82 families, all run. The `--ran` reconciliation reads "82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED". - Two gates first exited 3 and were re-run green after a rebuild. `check:dual-build-cjs-loads` was missing some workspace dists. `check:type-check-debt` saw stale spec and platform-objects entry points, caused by this run's own ablation and extract. - **ESLint, narrowed and proven.** `eslint --no-inline-config --format json` over the two changed `.ts` files: 2 files, 0 errors, 0 warnings. - Population: both files resolve a config under `--print-config` (neither is ignored), and `pnpm lint` is `eslint .`. - Invariance: the resolved `parserOptions` are `{ ecmaVersion: 'latest', sourceType: 'module' }`, with no `project` or `projectService`. Linting is therefore not type-aware, so this diff cannot change a verdict on any untouched file. ## Acceptance notes - **The branch is behind `origin/main`, without overlap.** `origin/main` has moved 4 commits past the base. None of them touches these three paths or any `packages/spec` generated artifact; the one `packages/spec` change upstream is in `src/contracts/`. I did not merge main in; CI's merge ref validates the joint tree. - **The titles are English in every locale for now.** `viewForm` declares no row children for `columns`, `sort` or `tabs`, so `os i18n extract` emits no catalog key for them and `resolveMetadataFormSchemaTitles` has nothing to overlay. The row-property localisation pin (`packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts`) scopes `view` out explicitly. The follow-on is to enumerate these children in `view.form.ts` with translated leaves. That is outside this card's file surface. - `#17232` (the parent family) remains open. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9282578 commit f5a7250

3 files changed

Lines changed: 59 additions & 43 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
The row properties of the `view.columns`, `view.tabs` and `view.sort` repeaters carry a JSON Schema `title`, so Studio's property panel stops printing raw machine keys as the column headers of those three tables (#17507).
6+
7+
Clause-②: no
8+
9+
Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. None of the 25 row properties of the three view repeaters carried a `title`, so the fallback arm ran and the maker saw `field` / `width` / `isDefault` / `order` in every locale, English included.
10+
11+
- **`view.columns`** — the 14 row properties of `ListColumnSchema` (`Field`, `Label`, `Width (px)`, `Alignment`, `Hidden`, `Sortable`, `Resizable`, `Wrap Text`, `Renderer Type`, `Pinned`, `Summary`, `Prefix`, `Primary Link`, `Click Action`).
12+
- **`view.tabs`** — the 9 row properties of `ViewTabSchema` (`Name`, `Label`, `Icon`, `List View`, `Filter`, `Display Order`, `Pinned`, `Default Tab`, `Visible`).
13+
- **`view.sort`** — the list view's INLINE `{ field, order }` sort entry (`Field`, `Direction`). It is not the shared `SortItemSchema`, so titling that schema never reached this table; the titles mirror it.
14+
- **Nothing the schema accepts or refuses moved.** `.meta({ title })` is presentation metadata: the generated `authorable-surface/` artifacts are byte-identical. The repeater-title ledger loses its last three entries and is now empty, so every repeater a form declares is fully titled and a new untitled one fails its own PR.

‎packages/spec/src/kernel/repeater-item-titles.test.ts‎

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@
3030
// and forgetting to delete its entry is also red, and the ledger can only
3131
// shrink.
3232
//
33-
// ⛔ Never add an entry to LEDGER to make this file green. An entry is a debt
34-
// record for a carrier that predates this pin (and, for the five below, one
35-
// held open by another PR's fence at the time it was written). A NEW untitled
36-
// repeater is the defect this file exists to catch.
33+
// ⛔ Never add an entry to LEDGER to make this file green. An entry was a debt
34+
// record for a carrier that predated this pin (and, for the last ones paid,
35+
// one held open by another PR's fence at the time it was written). A NEW
36+
// untitled repeater is the defect this file exists to catch.
3737

3838
import { describe, it, expect } from 'vitest';
3939
import { z } from 'zod';
@@ -81,26 +81,20 @@ const FORMS: ReadonlyArray<readonly [string, unknown]> = [
8181
];
8282

8383
/**
84-
* Carriers still owed titles, as measured on `origin/main` at
85-
* e758131b3900eb13260f03643e295ca6d625c42b. SHRINK-ONLY — see the header.
86-
*
87-
* The remaining `view.*` entries were fenced out of #17232's round by an
88-
* in-flight PR on their carrier file (#17360 `view.zod.ts`). This pin
89-
* OBSERVES them without editing them, which is why the set below is the rest
90-
* of the class and not the slice one PR could reach.
84+
* Carriers still owed titles. SHRINK-ONLY — see the header. EMPTY is its
85+
* steady state: every repeater a form declares is fully titled, so a new
86+
* untitled one is red on the day it lands.
9187
*
9288
* `dashboard:widgets` and `dashboard:globalFilters` were paid by #17505,
9389
* `field:options` and `object:fields.options` by #17506 — one edit for both,
9490
* because the two carriers resolve to the SAME `SelectOptionSchema` object
9591
* (`FieldSchema.options` is `z.array(SelectOptionSchema)` and `object.fields`
96-
* is a record of that same `FieldSchema`). All four are DELETED from this set
97-
* — a paid debt leaves no entry behind.
92+
* is a record of that same `FieldSchema`). `view:columns`, `view:sort` and
93+
* `view:tabs` were paid by #17507 — `view:sort` through the list arm's INLINE
94+
* `{ field, order }` entry, which is not the shared `SortItemSchema`. All
95+
* seven are DELETED from this set — a paid debt leaves no entry behind.
9896
*/
99-
const LEDGER: ReadonlySet<string> = new Set([
100-
'view:columns',
101-
'view:sort',
102-
'view:tabs',
103-
]);
97+
const LEDGER: ReadonlySet<string> = new Set<string>([]);
10498

10599
/** `z.never().optional().describe('[REMOVED] …')` — `shared/retired-key.ts`. */
106100
const RETIRED_PREFIX = '[REMOVED] ';

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,29 +1015,35 @@ export const ListColumnSchema = lazySchema(() => strictObject({
10151015
surface: 'this list column',
10161016
history: VIEW_HISTORY,
10171017
}, {
1018-
field: z.string().describe('Field name (snake_case)'),
1019-
label: I18nLabelSchema.optional().describe('Display label override'),
1020-
width: z.number().positive().optional().describe('Column width in pixels'),
1021-
align: z.enum(['left', 'center', 'right']).optional().describe('Text alignment'),
1022-
hidden: z.boolean().optional().describe('Hide column by default'),
1023-
sortable: z.boolean().optional().describe('Allow sorting by this column'),
1024-
resizable: z.boolean().optional().describe('Allow resizing this column'),
1025-
wrap: z.boolean().optional().describe('Allow text wrapping'),
1026-
type: z.string().optional().describe('Renderer type override (e.g., "currency", "date")'),
1018+
field: z.string().describe('Field name (snake_case)').meta({ title: 'Field' }),
1019+
label: I18nLabelSchema.optional().describe('Display label override').meta({ title: 'Label' }),
1020+
width: z.number().positive().optional().describe('Column width in pixels').meta({ title: 'Width (px)' }),
1021+
align: z.enum(['left', 'center', 'right']).optional().describe('Text alignment').meta({ title: 'Alignment' }),
1022+
hidden: z.boolean().optional().describe('Hide column by default').meta({ title: 'Hidden' }),
1023+
sortable: z.boolean().optional().describe('Allow sorting by this column').meta({ title: 'Sortable' }),
1024+
resizable: z.boolean().optional().describe('Allow resizing this column').meta({ title: 'Resizable' }),
1025+
wrap: z.boolean().optional().describe('Allow text wrapping').meta({ title: 'Wrap Text' }),
1026+
type: z.string().optional().describe('Renderer type override (e.g., "currency", "date")')
1027+
.meta({ title: 'Renderer Type' }),
10271028

10281029
/** Pinning (Airtable-style frozen columns) */
1029-
pinned: z.enum(['left', 'right']).optional().describe('Pin/freeze column to left or right side'),
1030+
pinned: z.enum(['left', 'right']).optional().describe('Pin/freeze column to left or right side')
1031+
.meta({ title: 'Pinned' }),
10301032

10311033
/** Column Footer Summary (Airtable-style aggregation) */
10321034
summary: z.union([ColumnSummarySchema, ColumnSummaryConfigSchema]).optional()
1033-
.describe('Footer aggregation for this column — the function alone, or { type, field } to aggregate another field'),
1035+
.describe('Footer aggregation for this column — the function alone, or { type, field } to aggregate another field')
1036+
.meta({ title: 'Summary' }),
10341037

10351038
/** Compound cell (Airtable-style): render another field inline before the value */
1036-
prefix: ColumnPrefixSchema.optional().describe('Field rendered inline before this cell value'),
1039+
prefix: ColumnPrefixSchema.optional().describe('Field rendered inline before this cell value')
1040+
.meta({ title: 'Prefix' }),
10371041

10381042
/** Interaction */
1039-
link: z.boolean().optional().describe('Functions as the primary navigation link (triggers View navigation)'),
1040-
action: z.string().optional().describe('Registered Action ID to execute when clicked'),
1043+
link: z.boolean().optional().describe('Functions as the primary navigation link (triggers View navigation)')
1044+
.meta({ title: 'Primary Link' }),
1045+
action: z.string().optional().describe('Registered Action ID to execute when clicked')
1046+
.meta({ title: 'Click Action' }),
10411047
}));
10421048

10431049
/**
@@ -1664,15 +1670,15 @@ export const ViewTabSchema = lazySchema(() => strictObject({
16641670
surface: 'this view tab',
16651671
history: VIEW_HISTORY,
16661672
}, {
1667-
name: SnakeCaseIdentifierSchema.describe('Tab identifier (snake_case)'),
1668-
label: I18nLabelSchema.optional().describe('Display label'),
1669-
icon: z.string().optional().describe('Tab icon name'),
1670-
view: z.string().optional().describe('Referenced list view name from listViews'),
1671-
filter: z.array(ViewFilterRuleSchema).optional().describe('Tab-specific filter criteria'),
1672-
order: z.number().int().min(0).optional().describe('Tab display order'),
1673-
pinned: z.boolean().default(false).describe('Pin tab (cannot be removed by users)'),
1674-
isDefault: z.boolean().default(false).describe('Set as the default active tab'),
1675-
visible: z.boolean().default(true).describe('Tab visibility'),
1673+
name: SnakeCaseIdentifierSchema.describe('Tab identifier (snake_case)').meta({ title: 'Name' }),
1674+
label: I18nLabelSchema.optional().describe('Display label').meta({ title: 'Label' }),
1675+
icon: z.string().optional().describe('Tab icon name').meta({ title: 'Icon' }),
1676+
view: z.string().optional().describe('Referenced list view name from listViews').meta({ title: 'List View' }),
1677+
filter: z.array(ViewFilterRuleSchema).optional().describe('Tab-specific filter criteria').meta({ title: 'Filter' }),
1678+
order: z.number().int().min(0).optional().describe('Tab display order').meta({ title: 'Display Order' }),
1679+
pinned: z.boolean().default(false).describe('Pin tab (cannot be removed by users)').meta({ title: 'Pinned' }),
1680+
isDefault: z.boolean().default(false).describe('Set as the default active tab').meta({ title: 'Default Tab' }),
1681+
visible: z.boolean().default(true).describe('Tab visibility').meta({ title: 'Visible' }),
16761682
}).describe('Tab configuration for multi-tab view interface'));
16771683

16781684
/**
@@ -2706,8 +2712,10 @@ const ListViewShapeSchema = lazySchema(() => strictObject({
27062712
id: VIEW_CONSOLE_ROW_ID_GUIDANCE,
27072713
},
27082714
}, {
2709-
field: z.string(),
2710-
order: z.enum(['asc', 'desc'])
2715+
// Titles mirror the shared `SortItemSchema` (`shared/enums.zod.ts`): this
2716+
// entry is an INLINE shape, so titling that schema never reached it.
2717+
field: z.string().meta({ title: 'Field' }),
2718+
order: z.enum(['asc', 'desc']).meta({ title: 'Direction' }),
27112719
}), {
27122720
// Only the spelling that used to be legal gets the retirement message; a
27132721
// number, an object, anything else keeps zod's default `invalid_type`.

0 commit comments

Comments
 (0)