Repository navigation
merge(ui): objectui becomes the ui/ workspace of this repository — subtree import at the frozen tip 9e1fe03286 plus the adaptation series (objectstack#25752) - #25825
Merged
Conversation
…ecord card (objectui#13102) (#13109) Fixes #13102 Clause-②: no ## What changes `record:details` no longer draws a record pointer pair's type half as its own row. An object can declare a polymorphic pointer as a pair: the id half (`record_id`) names its sibling with `referenceVia` (`object_name`). `DetailSection` already draws the id half as the record card, headed by the target object's label. The type half also drew as a row of stored machine name beside that card. `foldPointerTypeHalves` in `packages/plugin-detail/src/renderers/record-details.tsx` runs after every other fold (field security, the strip dedupe, the H1 dedupe). It reads the object's declared fields: every field with a non-empty `referenceVia` that names a declared sibling forms a pair. When the id half is drawn anywhere in this body (the direct `fields` list or any section, `{ group }` references included, since they resolve before the fold), the type half leaves every list of the body. - **Declared pair only.** No field name is read. A text field called `object_name` that no field names in `referenceVia` still draws. - **Only the row goes.** The record (`ctx.data`) is untouched, so the card still resolves the pair from the type half's value, and every other reader still reads it. - **Unchanged:** an object with no pair, and the id half's card. One choice beyond the card's wording: the type half is folded only when its id half is drawn in the same body. If the body does not draw the id half (for example, the author listed `object_name` but not `record_id`), no card on this body names the target object, so the type half keeps its row. Without this condition, the object type would be shown nowhere on the body. Pinned by the third case below. No export, prop, key or spec surface moves. The helper is module-private. ## Tests New: `packages/plugin-detail/src/renderers/__tests__/record-details.pointerTypeHalf-13102.test.tsx`, through the real `RecordDetailsRenderer` in a `RecordContextProvider`, with a probe face registered under `RECORD_POINTER_CARD_TYPE`: 1. (a) a pair, `sections` body: one card, and it receives `{ objectName: 'invoice', recordId: 'inv_1' }` (so the type half still feeds the card). No `Target Object Type` label and no `invoice` text in the body. 2. (a) a pair, direct `fields` body: the same fold. 3. a pair whose id half the body does not draw: the type half keeps its row. 4. (b) control: the same field names with no `referenceVia` draw every listed field, `object_name` included. Both fixtures declare `nameField: 'name'`, so the page-H1 dedupe takes `name` and none of the fields under test. A first run without it showed the H1 dedupe hiding `object_name` on the control object. **Ablation** (via `scripts/ablation-replace.mjs` in the objectstack checkout, anchor `const body = foldPointerTypeHalves(filteredFields, filteredSections, objSchemaFields);` replaced with the unfolded pass-through), on HEAD `5a5fd3415e`: anchor 1 to 0, blob `90106dfff7e2` to `570f6724e181`. Cases 1 and 2 went red (`Tests 2 failed | 2 passed (4)`), and the controls stayed green. Restore: blob after restore `90106dfff7e2` equals the HEAD blob, and `git diff HEAD` is empty. The test imports the renderer by relative path from `src`, so no `dist` build sits between the mutation and the run. The same ablation also ran on the first commit `83fdc295c0`, with the same result. ## Gates (worktree HEAD `5a5fd3415e`, base `df943a2a1a`) - Closure build `pnpm --filter '@object-ui/plugin-detail^...' build`, then `pnpm --filter @object-ui/plugin-detail type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `--listFiles` on `tsconfig.test.json` counts the new test file once. - vitest, `--maxWorkers=1`, under the shared lock: the new file `4 passed (4)`. The family that reads the touched file (all 22 `renderers/__tests__/record-details.*` files plus `__tests__/DetailSection.recordPointer-12045.test.tsx`): `Test Files 23 passed (23)`, `Tests 173 passed (173)`. - **Declared to CI:** the full `packages/plugin-detail/` directory. The dispatch order bars whole-package-directory runs in this container. - `eslint --no-inline-config` on the two changed source files: exit 0, 0 errors. `record-details.tsx` carries 29 warnings, the same count as its `origin/main` version, and the new test file carries 0. - Battery, all exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `check:new-line-citations`, `scripts/check-hand-rolled-comment-mask.mjs`, `scripts/check-changeset-fixed.mjs`, `scripts/check-changeset-presence.mjs`. - `check:spec-floors`: exit 1, `no-artifact` (no full workspace build in this worktree). Recorded, not fixed, as the order says. ## Changeset `.changeset/13102-details-pointer-type-half.md`: `@object-ui/plugin-detail` `patch`. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Session: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB` --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…lared-pointer rung (#13110) Fixes #13103 Clause-②: no ## What `page:header`'s declared-pointer rung (`declaredTitle`) now reads the record through the same `withOptionLabels` copy the `titleFormat` rung already reads. A select `nameField` (or `displayNameField`) heads the record page with its option label, for example `Config change`, not its stored value `config_change`. The copy is computed once (`labelledData`) and both rungs read it, so the option lookup is not restated: `optionLabelFor` stays the one place this file maps an enum value to its label, and its doc comment now names both rungs. Unchanged: a text `nameField` (it has no `options`, so `withOptionLabels` leaves its value as served), the rung order (explicit title, declared pointer, `titleFormat`, resolver, record-key net, placeholder floor), and the field-level-security reads (a masked field still arrives absent; the labelled copy only rewrites a top-level scalar the record carries). ## Tests New `packages/components/src/__tests__/page-header-title.selectNameField-13103.test.tsx` renders the real `page:header`: - (a) an object with `nameField: 'action'`, a select whose `config_change` option is labelled `Config change`: the H1 is `Config change`; the same with a `titleFormat` beside it (the pointer still answers first, with the label); the array form of `fields`. - (b) CONTROLS: a value no option declares renders raw; a text `nameField` whose value equals another select field's option value is unchanged; the `titleFormat` rung still labels a select token. Ablation (one-off, no permanent file): `objectstack/scripts/ablation-replace.mjs` swapped `recordDisplayValueAt(labelledData, declaredNameField(objSchema))` back to read `data` (anchor 1 to 0, blob `c4acc45de256` to `b4fadf1fe8ee`). The new file went `3 failed | 3 passed (6)`: the three label pins red (`Expected: "Config change"`, `Received: "config_change"`), the three controls green. Restore: blob after restore `c4acc45de256` equals the HEAD blob, `git diff HEAD` empty. ## Gates (on `74fdaee25e`) - Closure build `pnpm --filter '@object-ui/components^...' build`: exit 0. - `@object-ui/components` `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0; the test config's file list includes the new test. - Targeted vitest: the four `page-header-title*` files, `4 passed (4)`, `42 passed (42)`; then every `packages/components` test file that names `page:header` or `layout/containers`, `36 passed (36)`, `493 passed (493)`. - `eslint` on the two changed source files: exit 0, 0 errors (warnings are the file's existing `no-explicit-any` set; the new test's three match its sibling's helper). - Pre-tsc battery, each exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `scripts/check-hand-rolled-comment-mask.mjs`, `scripts/check-changeset-fixed.mjs`, `scripts/check-changeset-presence.mjs`, `check:new-line-citations`. - Declared to CI, not run locally: the full `packages/components` directory, and `packages/app-shell` tests that render `page:header` (record detail views). ## Acceptance notes - `unifiedTitle` (the resolver rung, `getRecordDisplayName`) still reads the served record. It is reached only after the declared-pointer and `titleFormat` rungs both decline, so it is not the path this card measured; whether its type-aware derivation can ever pick a select field was not measured here. Noted, not changed (outside this card's ruling); carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…, never a derived field (#13112) Fixes #13107 Clause-②: no ## What this changes The bulk-action confirm step lists each affected record by the object's declared `nameField`. Before this change, when no column of the view showed that field, the step listed another text field in its place. Measured on HotCRM's All Contacts list, it showed job titles instead of contact names. Two halves, both in `@object-ui/plugin-grid`: - **Harvest (`ObjectGrid` `getSelectFields`).** The grid's own `$select` now asks for the object's declared `nameField`. It is read through `declaredNameField`, the resolver's single spelling of that pointer. A formula field is requested by name, because the server serves formulas. It is fetched, not drawn: it joins the projection, not the columns. It goes through the same two gates as the grid's other extra fields: `isProjectableField` (the object must declare it) and `passesProjectionGate` (the viewer may read it). It joins the existing union, so a `nameField` that is already a column is sent once. This is the projection a standalone `object-grid` sends, and the one every group row page of a server-grouped grid sends. The measured All Contacts view is grouped by account (`grouping.fields: crm_account`), so that is the path its rows take. - **Floor (`BulkActionDialog` confirm step).** A new module-local `affectedRecordName` decides the name. An object with no declared `nameField` keeps `getRecordDisplayName`'s full ladder, unchanged. For an object that declares one, the step uses the declared value, or the resolver's own `Record #ID` floor when the row lacks it. The derive steps (4 and 4b) are never reached for such an object. Two cases leave the row without the value: the server did not answer it, or the viewer may not read it. No change to `getRecordDisplayName`'s global ladder in `@object-ui/core` (triage ruling). Nothing is added to the package entry: no export, prop or type member. ## Pins (`packages/plugin-grid/src/__tests__/bulkConfirmNameField-13107.test.tsx`) Each pin drives the real ObjectGrid and BulkActionDialog. The data source honours `$select` the way a server does: a row carries the selected keys only. - (a) Formula `full_name` as `nameField`, with columns `first_name` / `last_name` / `title`. The `$select` carries `full_name`, the confirm step lists `John Smith` and `Marcus Reed`, and no column header is drawn for it. - (a) The same object on a server-grouped grid: every group row page's `$select` carries `full_name`. - (b) The server leaves `full_name` off the answer: the step lists `Record #c1` and `Record #c2`, never the job titles. - (b) The viewer may not read `full_name`: it is not requested, and the step lists `Record #ID`. - CONTROL (c): a `nameField` that is a column (the lead list shape) is sent once and listed as before. - CONTROL (c): an object with no `nameField` asks for nothing more and keeps today's derive ladder. It lists the titles, as before. ## Ablations (one-off, via `scripts/ablation-replace.mjs` in the objectstack checkout; no permanent test) Each mutation landed on disk with the anchor count going 1 to 0. Each was restored and checked: the blob equals HEAD and `git diff HEAD` is empty. 1. Drop the harvest (`...nameFields` removed from `extraFields`). Blob `c025fe82f5a1` became `49fa09861016`. 3 of 6 went red: both (a) pins and the first (b) pin, because that pin asserts `full_name` in `$select`. Restored to `c025fe82f5a1`, the HEAD blob. 2. Drop the floor (the `affectedRecordName` condition forced to the full ladder). Blob `15c5e833e1ec` became `62e734d67541`. Both (b) pins went red, and each received `VP of Engineering` and `Director of Commercial Systems`. That is the card's symptom, reproduced. Restored to `15c5e833e1ec`, the HEAD blob. ## Gates (final head `0d08662299`; source last changed at `85d07587eb`, later commits are the test file and the changeset) - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' build`: exit 0. - `pnpm --filter @object-ui/plugin-grid run type-check` (`tsc --noEmit` plus `tsc -p tsconfig.test.json`; the new test is in that program by `--listFilesOnly`): exit 0 on `0d08662299`. - Targeted vitest, `--maxWorkers=1`, run under the shared lock: the new file passes 6/6 on `0d08662299`. The families that touch these files ran on `85d07587eb`: 69 files, 1771 tests passed. That set is every plugin-grid test mentioning `$select` or the resolver, plus every test anywhere naming `ObjectGrid.tsx` or `BulkActionDialog`. The full `packages/plugin-grid/` directory is declared to CI. - `eslint` on the three changed source and test files: 0 errors. The warnings are `no-explicit-any` in the test file, the same style as the precedent `bulkConfirmRecordTitles-12332` test. - Battery: `check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`, `control-bytes`, `new-line-citations`, `check-hand-rolled-comment-mask`, `check-changeset-fixed` and `check-changeset-presence` all exit 0. `check:spec-floors` exits 1 with `no-artifact`, because the workspace is not fully built. This is a known state of main and is recorded, not acted on. ## Acceptance notes - **The other projection, outside this card's file surface.** An UNGROUPED list view is fetched by `plugin-list`'s `ListView`, which hands the rows to the grid. Its `$select` harvest has no arm for the declared `nameField`. On such a list, a `nameField` that is not a column now reads as `Record #ID` in the confirm step, not as a wrong name, but it is still not the name. The repair is a single `addSpeculative` call for the declared `nameField` in that harvest. `ListView` already has the matching arms for `rowColor` and `referenceVia`. That is plugin-list, outside the claim, so it is reported to the PM seat and not done here. - `changeset`: `@object-ui/plugin-grid` patch. The session that wrote this: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…when declared (#13117) Fixes #13099 Clause-②: no ## What The create entry uses the object's `create` form view when the object declares one, else today's `form` / `formViews.default`. An edit is unchanged. This is ruling B′ on #24692 (ruling comment 6281834942), scoped by the director's pointer 6281857581 on the card. Its first bullet, quoted verbatim as the director asked: > **One selection line, pinned by a test.** The create entry (`packages/app-shell/src/utils/recordFormNavigation.ts:218`) uses `objectDef.formViews?.create` when the object declares it, else today's `objectDef.form ?? objectDef.formViews?.default`. This is the documented create-vs-edit model (`content/docs/ui/create-vs-edit-form.mdx`) made true by convention; it does not wait on the spec PR. - `recordFormNavigation.ts`: new `selectRecordFormView(objectDef, flow)` holds the one selection. `resolveFormViewLayout` and `resolveRecordFormModalLayout` take an optional `flow` (`'create' | 'edit'`). A caller that names no flow gets the default form view, the same answer as before. - `AppContent` (the console's New/Edit modal) passes `editingRecord ? 'edit' : 'create'`. That is the same test that sets the modal's own `mode`, which comes from the `?form=` URL param (`new` or a record id). So the selected view always matches the mode the form opens in, and an edit cannot reach the create selection. - `useActionModal` (action-opened object forms) passes the flow from the same `mode` the `ModalForm` opens in. `view` and `edit` keep the default form view. ## The served key: `OBJECT.create`, not a bare `create` (mechanism, measured) The pointer's literal spelling `objectDef.formViews?.create` does not match the runtime shape. A container's `formViews.create` expands to the form item named `OBJECT.create` (`expandViewContainer` in `@objectstack/spec/ui`). `MetadataProvider.mergeViewsIntoObjects` keys `objectDef.formViews` by that served name. The existing pin "`showcase_contact` keeps its default whichever order its two forms arrive in" (`MetadataProvider.merge.test.ts`) asserts the keys `showcase_contact.create` / `showcase_contact.form`. A literal `formViews.create` read would therefore never match the documented showcase example, and a pin on a hand-built fixture would pass anyway. The selection reads `formViews[OBJECT.create]`, and the new pin builds its fixtures from served rows run through the real merge. No second spelling is read; there is no `??` alias between the two. ## Not moved (per the ruling) There is no `mode` / `formView` plumbing and no inline add. The Add button keeps its position and its `enabled: true` gate. The `p1-spec-alignment` parity row is untouched. ## Pins (`packages/app-shell/src/utils/__tests__/createFormView-13099.test.ts`) - (a) `showcase_contact` (a `create` form view whose sections and size differ from the default `form`): a create selects the `create` view's sections and `modalSize`, through `resolveFormViewLayout` and `resolveRecordFormModalLayout`. - (b) An edit of the same object keeps the default `form`, which is tabbed with different sections. A call with no flow gives the same answer as an edit. - (c) CONTROL: `crm_lead` has a named Web-to-Lead form and no `create` form. Its create uses `form`, and the named form is never picked. The legacy `formViews.default` is still the create fallback. A named-only object creates with no layout. **Ablation**, run through `ablation-replace.mjs` at HEAD `2e759c1083`. The mutation replaces `if (createView) return createView;` with a no-op, and the anchor goes from 1 hit to 0. Result: (a) red with `expected [ 'identity', 'notes' ] to deeply equal [ 'quick' ]`, (b) and (c) green (`1 failed | 2 passed`). After the restore, the blob is `df4f0b151ec4b5ebaef09a6aef65424f38e6c873`, equal to HEAD, and `git diff HEAD` is empty. ## Verification (HEAD `2e759c1083`) - `pnpm --filter '@object-ui/app-shell^...' build` (the closure build): exit 0. Then `pnpm --filter @object-ui/app-shell type-check`: exit 0. That is `tsc --noEmit` plus `tsc -p tsconfig.test.json`, and `--listFiles` counts the new test file in the test project. - Targeted vitest (`--maxWorkers=1`, under the lock): the new pin plus every test file naming `recordFormNavigation` / `useActionModal` / `AppContent`, 99 files in three batches. Results: 34 files / 260 tests, 31 / 235 and 35 / 338, all passed. - **Declared to CI:** the full `packages/app-shell/` directory. The dispatch ruled out whole-package-directory runs locally. - Lint, narrowed to the 4 changed `.ts`/`.tsx` files with `eslint --no-inline-config --format json`: 4 files, 0 errors. The warnings are 43 / 15 / 5 on the three edited files, equal to their base-commit counts, and 0 on the new test. The root `eslint.config.js` names no `parserOptions` / `projectService` / type-checked preset, so linting is not type-aware. This diff cannot move any untouched file's verdict. - Pre-tsc battery: `check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`, `control-bytes` and `new-line-citations` all exited 0. So did `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs` and `check-changeset-presence.mjs`. - `check:spec-floors`: exit 1, `no-artifact`. The workspace is not fully built, so this is NOT MEASURED, recorded and not fixed, as the dispatch says. ## Acceptance notes - When the `create` form view is selected, it is the whole form: its own sections, layout, size and subforms. Model-derived inline subforms (`attachInlineSubforms`) attach to the default form, so they do not appear on such a create. - A list view that also uses the key `create` in the same container takes the name `OBJECT.create` first. The form is then renamed `OBJECT.create_2` by the expansion, which stamps its own collision warning, and the create falls back to the default form. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… shipped showcase or retired (objectui#13104) (#13132) Fixes #13104 Clause-②: no Every row has its conclusion: the card's eleven, plus `studio-editor`, which the claim's amendment (comment `6283451551`) added as row 12. Eleven specs are repaired against the current surface and the shipped showcase. One case is deliberately retired (`user-filters`' lookup quick filter). `screen-flow` needs no change because its red does not reproduce. ⛔ No product source is touched; the diff is eleven files under `e2e/live/`. No `test.skip` / `test.fixme` is added, and `record-history-display`'s skip guard is gone. This is a resume dispatch. The first dev's draft (`2f09525`) was unverified when its container restarted. Every row below was re-measured, red on BASE `df943a2` and green on this head. Three commits on top of the draft change what did not hold (R1, R4, R5 below). Patch round 1 adds `8493767` for row 12. Session: `https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7`. ## How it was measured - **Backend (the one the card names, "the shipped showcase"):** objectstack `main` `d20bf9b09`, whose `.objectui-sha` is the shipped console pin `12dc7b47c340`. Its `examples/app-showcase` ran from a built objectstack worktree with `objectstack dev --seed-admin --fresh -p 4113`, on a fresh database for each whole-suite run. - **Console:** this worktree's console Vite dev server (`cd apps/console && DEV_PROXY_TARGET=http://localhost:4113 pnpm exec vite --port 5213 --strictPort`). The diff touches only `e2e/live/`, so the console source under test is BASE's. - **Runner:** `pnpm exec playwright test` with `playwright.live.config.ts` wrapped only to add `executablePath: /opt/pw-browsers/chromium`. This container's Playwright 1.62.1 wants chromium revision 1234, and 1194 is the one installed. The BASE specs ran from a `git archive df943a2 e2e/live` copy. - **Whole-suite runs:** every spec under `e2e/live/`, in one run, alphabetical, one worker, as the Tier 1 run (#24598) ran them. - BASE: 46 passed, 15 failed or timed out, 1 skipped. - Head `3afb8d1` (round 1): 59 passed, 2 failed, both `studio-editor`. - Head `8493767` (patch round 1, row 12 repaired): 61 passed (11.0m), exit 0. Every spec is green, `studio-editor` included. - **Control backend (H2):** objectui's own CI-lane backend: `e2e/live/ci/start-backend.sh`, published `@objectstack/*@18.0.0-next.5` plus the showcase at its release tag (`820fd5ae`), on `:4114`, with a second console dev server on `:5214`. The head `3afb8d1` whole-suite run there gave the same reading as the primary: 59 passed, 2 failed, both `studio-editor`. In patch round 1 the repaired `studio-editor` also ran there at `8493767`: 2 passed. `backend.env` is not edited. ## Per-row conclusions Before = BASE `df943a2`, after = the final head, both read from the whole-suite runs above (tests passed / tests in the spec). | spec | cause, re-measured at BASE | conclusion | before → after | |:--|:--|:--|:--| | `studio-object-designer` | F1 timed out: it opens the Studio app, which a stock boot does not serve. F4: the banner is "A `select` field needs its choices", for a picklist with no option source | repaired: F1 on the Studio surface's package switcher (R4); F4 gives the picklist one option before adding the empty row | 2/4 → 4/4 | | `studio-record-page` | `getByLabel(/^Object/i)` resolves first to the app bar's "ObjectOS home" link, and the Object control is a `role=combobox`; Page type now defaults to list | repaired: anchored label, combobox and option, Page type set to record | 0/1 → 1/1 | | `user-filters` | tabs: no `view-tab-urgent` (ADR-0053 made tabs an interface-page element); switcher: not in the toolbar; hollow view: `cell` where the grid exposes `gridcell`; lookup filter: no filter bar on `showcase_task.tabular` | repaired: tabs on the Task Triage page, the switcher through the Display popover with the exact shipped whitelist, `gridcell`. The lookup case is retired (R2) | 2/6 → 5/5 | | `inline-edit-polish-2572` | the lock poll read 8 edit affordances where 0 were expected: the request opens on `manager_review` (`lockRecord: false`); the spec also spent the seeded project | repaired: its own project, and the lock asserted at `exec_review` (R1) | 0/1 → 1/1 | | `field-conditional-rules` | no `tax_rate` input where `toBeDisabled` waited: a `readonlyWhen` field renders the read-only field group | repaired: the input is gone and the read-only group is shown under `paid`, and back to an editable input under `sent` / `draft` | 0/1 → 1/1 | | `grid-file-upload` | `getByText('Line Items')` matches the sr-only DialogDescription and the section heading (strict mode). The second cause (ADR-0104 stored form) sits behind it | repaired: the section found by its heading role; the receipt is a file id, read back byte for byte through `GET /api/v1/storage/files/ID` | 0/1 → 1/1 | | `form-view-subforms` | the same `Line Items` collision | repaired: heading role | 0/1 → 1/1 | | `cascading-options` | no `md-form-submit` on `showcase_cascade`'s flat ModalForm | repaired: waits for the ModalForm's `modal-form-footer` | 1/2 → 2/2 | | `detail-related-list` | `waitForURL(/[?&]recordId=/)` timed out: a row's name link opens `/record/ID` | repaired: the id read from the route, on Northwind; the primary Projects list in its own tab (R3) | 0/1 → 1/1 | | `record-history-display` | skipped by its app-todo guard | repaired: targets `showcase_account`, with no skip guard (R5) | skipped → 1/1 | | `screen-flow` | passes at BASE, both alone and in the whole-suite order | no change: the card's red does not reproduce (below) | 1/1 → 1/1 | | `studio-editor` (row 12) | the shipped `showcase_project_workspace` opens read-only (lock `no-overlay`), so there is no "Add block"; and the page form has no "Layout" section heading. Both are intended (below) | repaired: each test authors an editable copy of the page; the Layout check reads where the layout renders, on the canvas | 0/2 → 2/2 | **`screen-flow` (read first, as triage asked).** No record names a cause for the Tier 1 red. #24598 says only "FAILED (known-stale, not consulted)". On the shipped showcase it passes at BASE alone and in the whole-suite order. objectui's own Live E2E lane (published `18.0.0-next.5`) also reads it green, judged by the step `Run live E2E allowlist` = success with no `continue-on-error`: - `main`'s scheduled run `39213623070`, at `4484a11`. That is three commits after the Tier 1 console pin `4016370a`, and the three touch record-page reads and publish tooling. - PR run `39359398003`, today. The job log itself answered 403 through this container's proxy, so the reading rests on the step conclusion. ## Review points **R1 · `inline-edit-polish-2572`: the lock assertion is kept.** - The draft swapped the lock for an "In approval · editable" reading. The lock is back, at a step that locks. - Before (BASE): poll until `page.locator('[title="Double-click to edit"]').count()` is `0` after the request opens. Then `lockedBudgetGroup.dblclick()` and `expect(saveBtn).toBeHidden()`. - Now: - (2a, an addition) at `manager_review`: "In approval · editable" is shown, and a double-click opens a session. - (2b) The test approves `manager_review` with `POST /api/v1/approvals/requests/ID/approve`. The seeded admin holds the `manager` position (`ADMIN_APPROVAL_POSITIONS` in the showcase's `seed-approval-demo.ts`). It waits for the pending `exec_review` request with `lock_record: true`. - Then it asserts the original lock unchanged: zero affordances after a fresh mount, and a double-click that opens nothing. It adds the "Locked for approval" band as a positive signal. - The budget edit moves from 160000 to 600000, because `exec_review` is reached only over the flow's decision edge for a budget above 500000 (`showcase_budget_approval` in the showcase's `automation/flows/index.ts`). - Measured: BASE red (8 vs 0); `46625db` green alone (24.3 s); green in the head whole-suite run. **R2 · `user-filters`' lookup quick filter: retired, with the measurement.** - `git grep -n userFilters d20bf9b -- examples/app-showcase/src` lists every declaration in the shipped showcase (positive control: `allowedVisualizations` is found in `task.view.ts`). - The views and pages read: - page `showcase_active_projects` (`health`, `status`); - page `showcase_task_triage` (tabs); - page `showcase_task_workbench` (`status`, `priority`); - `project.view.ts` (`status`, `health`). - `task.view.ts` declares none by design; its own ADR-0053 note says so. - Every one of those fields is `Field.select`. The task object's one lookup, `project` (master-detail), is a user filter nowhere. So no shipped fixture can drive the case, and it is deleted. - Unit reading (not a diff): four plugin-list tests render the lookup filter picker (`filter-lookup-FIELD`): - `UserFilters.declaredDisplayField-10545` - `UserFilters.lateTypeCoercion-12008` - `UserFilters.pendingLabel-12890` - `UserFilters.retiredFieldType` - 10545 pins the picker's display-field read. None of the four picks a record through the picker: the selection is seeded with `initialSelections`, and 12008's emitted-filter assertions are on boolean and numeric fields. - **Coverage lost:** picking a record in a lookup quick filter narrows the list, persists as a `uf_FIELD` URL param and badges the count. Nothing pins that end to end now. **R3 · `detail-related-list`: Northwind and its related list are in the shipped seed.** - `examples/app-showcase/src/data/seed/index.ts` seeds the account `Northwind` and its projects "Website Relaunch" (active) and "Legacy Sunset" (completed). - `examples/app-showcase/src/data/objects/project.object.ts` declares the `account` lookup with `relatedList: 'primary'` and `relatedListTitle: 'Projects'`. - "Rule Z" is objectui's own default in `buildDefaultPageSchema.ts`: an `isPrimary` related list gets its own tab. It is pinned by the plugin-detail test "promotes an isPrimary related list to its OWN tab (rule Z default)". **R4 · `studio-object-designer` F1: the title's promise holds, and the mechanism coverage is narrower.** - Before (BASE): create the package on the Studio app's `developer/packages` page, then `getByTestId('package-switcher').click()` and `expect(getByRole('option', { name: pkgName })).toBeVisible()`. - After: on `/studio/com.example.showcase/data`, create the package through the switcher's "New package" dialog, with no reload. Then: - `expect(chip).toContainText(pkgName)`; - the open switcher lists a button named by the package's name followed by its id. This commit tightened the draft's id-only match to the name the refreshed list carries. - Why it moved: the sidebar `package-switcher` (`ContextSelectors.tsx`) renders only for an app that declares `contextSelectors`. A stock showcase boot serves `showcase_app`, `setup` and `account` (`GET /api/v1/meta/app`), and none declares one. - What it no longer pins: the original watched a switcher that stays mounted while a package is created elsewhere, so it pinned the `objectui:packages-changed` refetch. On the Studio surface the create dialog navigates into the new package, and the switcher's list effect is keyed on the package id, so the route change alone refetches the list. - No unit test pins either switcher's `objectui:packages-changed` refetch. The only test that names the event is `packages-io.inflightShare-11797`, and it pins the in-flight read drop. - The draft cited ADR-0048 for "no stock boot loads the Studio app". objectstack's ADR-0048 is about cross-package metadata collision, so both comments now state the reading and its instrument instead. - F4's BASE banner was the "needs its choices" error, not the "System identifier must be" error F4 guards. Giving the picklist an option first keeps F4 on its own bug. **R5 · `record-history-display`: `showcase_account` declares `enable: { trackHistory: true }`** in `examples/app-showcase/src/data/objects/account.object.ts`. - It is the only showcase object that does. - Its fields, with the extension's, carry no boolean and no datetime. So the original's booleans-as-words and datetime legs cannot be driven on the showcase. Both stay pinned in app-shell's `auditHistoryDisplay.test.ts` ("localizes booleans through t with Yes/No fallback", "localizes datetime values instead of raw ISO strings"), and the spec header now says so. - This head also asserts the "Customer Since" diff line before asserting no raw `YYYY-MM-DD`, so that check cannot pass on an absent line. Read once: "Customer Since: 7/26/2026 → 8/4/2026". ## H4 `grid-file-upload` and `form-view-subforms` share the `Line Items` cause. Each spec now finds the section by its heading role. `e2e/live/helpers.ts` is unchanged, so no other spec's reading moves. ## Row 12 · `studio-editor` (patch round 1) **Correction first.** Round 1 said "Tier 1 read it green at `4016370a`", and that was wrong: it was inferred, not measured. Tier 1 (#24598) never ran this spec. For `studio-authoring.record-page-roundtrip`, its pin set is "files 0/1 … (studio-record-page.spec.ts …)", and `studio-editor` appears nowhere in that record. Nothing moved between Tier 1 and now. #7753 item 6 (closed 2026-08-11) had already recorded both causes against QA run #7695: "targets a 'Layout' heading that no longer exists, plus a **shipped** page the editor correctly locks". objectstack's checklist carries the same record as `automated.stale` on that item. **The producers, and whether each state is intended.** Both states are intended. - **The lock.** `page` is registered create-only: `supportsOverlay: true, allowEnvironmentOverlay: false, allowRuntimeCreate: true` (`metadata-plugin.zod.ts`). ADR-0005's override table lists `page` as ❌ ("Conservative default — these bind to routes and side-effects"). Its addendum "Two-tier authorization (PR-10d.7, 2026-05)" says "users may author brand-new items of this type, but artifact-shipped items remain immutable". - objectui's `ResourceEditPage` applies that rule in its own two-tier gate ("artifact-backed items need allowEnvironmentOverlay"). - Since #21670 (closed 2026-10-04), the layered read's protection envelope reports the same refusal: `servedLockState` joins `packagedBaseRefusal`. Read on the shipped showcase: `lock: no-overlay`, `editable: false`, `provenance: package`. That envelope is what draws the "This item is locked" banner. - The exact commit that introduced the console gate is NOT MEASURED. Both clones here are shallow (objectui from 2026-10-13, objectstack from 2026-09-16), and the commit-search endpoint answered 403 here, its control query included. The ADR names the rule, and #7753 dates the state to 2026-08-11 or earlier. - **The missing "Layout" heading.** objectui `29bc4bf00f` (objectui#2308, 2026-07-06) prunes `name` / `regions` / `children` from the page form (`PAGE_FIELDS_OWNED_ELSEWHERE`, `page-schema.ts`): the block tree is "edited on the canvas", and no bare section header is left behind. The spec form's "Layout" section holds only `regions`, so it does not render. **The repair keeps every assertion and re-points the two stale ones.** - Each test authors an editable copy of the shipped page in the environment: GET `showcase_project_workspace`, PUT it under `e2e_studio_editor_STAMP`, and DELETE it in `finally`. The copy reads `lock: none`, `editable: true`, and after a run the page list holds no `e2e_` leftovers. - "Config sections": the Basics heading, as before, plus the Data Context heading, plus a guard that no lock banner stands in front of the editor. The guard is a precondition, not a replacement. - "Layout": read where the layout renders now, on the canvas. There is one Add block per region (the count is derived from the copied page's `regions`), and the canvas offers Add region. On the shipped page none of these render. - Add block, and the picker's Card / Section / Record details, are unchanged. Each match is unique among the picker's 33 entries. - Measured: BASE 0/2 (no "Layout" heading; no Add block, timeout); `8493767` 2/2 alone, and green in the final whole-suite run. ## Gates (head `8493767`, exit codes captured before any pipe) - `pnpm type-check:e2e`: 0 - `pnpm exec eslint` over the eleven changed specs: 0 errors, 11 warnings (BASE bytes of the same files: 12) - `pnpm check:new-line-citations`: 0 (0 new citations) - `pnpm check:control-bytes`: 0 - `pnpm check:test-path-roots`: 0 - `node scripts/check-changeset-presence.mjs`: 0. No source or published contract of a released package changed, so no changeset is owed. - The live suite table above is the acceptance reading. ## Acceptance notes - The objectstack checklist's `automated.stale` markers for these specs are the objectstack `domain:devx` seat's to drop. ⛔ They are not edited here. - Coverage this PR does not restore, named once more: - the lookup quick-filter pick (R2); - the mounted-switcher `objectui:packages-changed` refetch (R4); - booleans and datetimes in History on the live stack (R5, unit-pinned). - Diff: 11 files, 353 insertions, 212 deletions. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…13158) Fixes #13116 Clause-②: no ## What this changes An ungrouped list view fetches its rows through `plugin-list` `ListView`. Its `$select` harvest (`addSpeculative`) had arms for view bindings, grouping, `rowColor`, `settledWhen` and `referenceVia`, but none for the object's declared `nameField`. So when no column showed that field, the rows arrived without it, and the bulk confirm step (`BulkActionDialog`, objectui#13107) fell to the `Record #ID` floor. This adds one arm, `addSpeculative(declaredNameField(objectDef))`, beside the `referenceVia` arm. `declaredNameField` is the same `@object-ui/core` helper `ObjectGrid` `getSelectFields` uses for this field (PR #13112), and plugin-list already imports `@object-ui/core`. The arm goes through the gates every other arm uses: the known-field gate (the object must declare it) and the FLS gate (the viewer may read it). It is fetched, not drawn: it joins the projection, not the columns. `required` is a set, so a `nameField` that is already a column is sent once. Nothing else moves; no export, prop or type member is added. ## Pins (`packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx`) The data source honours `$select` the way a server does: a row carries the selected keys only. plugin-list does not depend on plugin-grid, so a stub grid stands in, and the confirm step's name is asked of the rule that step applies (`recordDisplayValueAt(row, declaredNameField(def))`, then `getRecordDisplayName`) against the rows the grid is handed. The real dialog is pinned on plugin-grid's side by `bulkConfirmNameField-13107.test.tsx`. - (a) Formula `full_name` as `nameField`, columns `first_name` / `last_name` / `title`: the `$select` carries `full_name`, no grid column is drawn for it, and the rows name `John Smith` and `Marcus Reed`. - CONTROL (b): an object with no `nameField` sends the same `$select` as before (`body`, `id`, `subject`). - CONTROL (b): a `nameField` that is a column is sent once. ## Ablation (one-off, via objectstack `scripts/ablation-replace.mjs`; no permanent test) Dropped the arm (anchor `addSpeculative(declaredNameField(objectDef));`, count 1 to 0). Blob `ecffd5bc77e4` became `978e1d188f1f`. Pin (a) went red: `expected [ 'first_name', 'id', …(2) ] to deeply equal [ 'first_name', 'full_name', …(3) ]`; the two controls stayed green (1 failed, 2 passed). Restored: blob equals HEAD (`ecffd5bc77e4`), `git diff HEAD` empty, anchor count back to 1. ## Gates (head `00aa971518`) - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-list^...' build`: exit 0. - `pnpm --filter @object-ui/plugin-list run type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `--listFiles` on the test config lists the new test file. - New test file: 3 passed. The `$select` family (the 26 plugin-list test files that name `$select`, the new one included): 26 files, 430 tests passed. - Full `packages/plugin-list/` directory: NOT MEASURED locally. It ran past the foreground ceiling on the shared test lock and was stopped; it is declared to CI. - ESLint on the two changed source files (`--no-inline-config`, `--format json`): 2 files, 0 errors. `ListView.tsx` carries 205 warnings, the same count as on `origin/main`. The config is not type-aware, so this diff moves no verdict on an untouched file. - Pre-tsc battery, all exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `scripts/check-hand-rolled-comment-mask.mjs`, `scripts/check-changeset-fixed.mjs`, `scripts/check-changeset-presence.mjs`, `check:new-line-citations`. - `check:spec-floors`: exit 1, `no-artifact` (the workspace is not fully built here). Recorded, not fixed, per the dispatch. ## Acceptance notes - One `@object-ui/plugin-list` `patch` changeset: `.changeset/13116-listview-namefield.md`. Implemented in session `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ Co-authored-by: Claude <noreply@anthropic.com>
…and word a refused policy name (#13167) Fixes #13113 Clause-②: no ## What changed Studio's row-level security editor no longer autosaves the blank row that "Add policy" inserts. - **Hold, don't send (ruling 1).** `PermissionMatrixEditPage` counts RLS rows whose `name` is empty or blank in the draft itself (`rlsUnnamedCount`). Any such row holds the shared `useDraftAutoSave` (`blocked`), the same way a CEL lint error does. It also disables the environment-door Save button. The row says beside itself "Give the policy a name to save it." (`rls-name-needed-N`), and the status line says the same in a neutral tone. The test is emptiness only: the spec's `^[a-z_][a-z0-9_]*$` is not restated in the client. - **Name the field (ruling 2).** When a save is refused, `rlsNameRefusal` reads the refusal's own `MetadataError.issues`. Each issue at `rowLevelSecurity.N.name` renders as "Not saved. Policy name, on row N+1: PROBLEM", where PROBLEM is the server's message passed through `translateValidationMessage`. Any other issue path keeps today's text, appended after the mapped lines. With no mapped issue, the strip text is unchanged. This only changes how the door's answer is displayed. It is not a second validator. - **The draft keeps the row (ruling 3).** The held row stays in the in-memory draft. A reload discards the unsaved draft, because nothing was persisted, so the row is gone after a reload. This is the same for any held edit, such as a CEL error. Ruling 3 explicitly allows this. - Copy: `perm.rls.nameNeeded` and `perm.rls.nameRefused` in the metadata-admin designer table, en and zh. - No package export, prop, type member or language-pack key is added. Changeset: `@object-ui/app-shell` patch. ## Verification (at `9a6cdafe39`) - New pins `PermissionMatrixEditor.rlsAddPolicyHold-13113.test.tsx`, driving the real `PermissionMatrixEditPage` against a fake client that refuses the way the door does: (a) an unnamed row sends no PUT within the autosave window, shows the hint, and shows no banner. (b) Naming it sends the save. (c) A 422 with `rowLevelSecurity.0.name [invalid_format]` reads as the policy-name message, with no raw path or zod code. (d) Controls: a CEL error still holds, and a refusal on another path keeps the transport's text. 5/5 passed. - Targeted family: `vitest run --maxWorkers=1` over `metadata-admin/PermissionMatrixEditor*` and `PermissionAdvancedFacets*`: 28 files, 196 tests passed. The metadata-admin i18n family (`CelPredicateField.i18n*`, `studio-locale.i18n`, `metadata-form-i18n`): 3 files, 22 tests passed. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. The test project's `--listFilesOnly` includes the new test file. The closure build `--filter '@object-ui/app-shell^...' build` ran first and exited 0. - Ablation 1 (drop the hold: `saveHeld = celErrorCount > 0`): (a) and (b) went red, 2 failed and 3 passed. Ablation 2 (drop the mapping: `setError(err?.message ?? String(err))`): (c) went red, 1 failed and 4 passed. Each mutation was landed and proven on disk by `ablation-replace.mjs` (anchor count 1 to 0, blob changed). Each restore was proven by blob hash: after restore, blob `864d2df7b5b3` equals the HEAD blob, and `git diff HEAD` is empty. - Lint, narrowed to the 4 changed `.ts`/`.tsx` files (`eslint --no-inline-config --format json`): 4 files, 0 errors, 23 warnings. All 23 sit on lines this PR does not add (`no-explicit-any` and others). The config enables no type-aware linting, so this diff cannot change a verdict on an untouched file. The whole-repo `pnpm lint` is left to CI. - Battery, all exit 0: `check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`, `control-bytes`, `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`. `check:spec-floors` exited 1 as a prerequisite failure ("no-artifact: build the workspace first"), so it is NOT MEASURED, as the dispatch expected on main. - Declared to CI: the full `packages/app-shell` directory test run. The shared lock was in use, so no whole-package run was done locally. ## Acceptance notes - `studio-design/metadataError.ts` already has a general `issueRefusal(e, locale, locate)` with an `IssueLocator`. It needs a `MetadataSelection` target and a `StudioRefusal` strip, which this permission editor's plain-string error strip does not have. So the mapping here is local to the editor. Moving the editor onto the `StudioRefusal` strip would be a larger change than this card covers. - Today, a nameless row the server refuses also carries a second issue (`rowLevelSecurity.0 [custom]`). With the hold in place, such a row is no longer sent. If a non-empty name is refused together with a row-level issue, the row-level issue keeps the raw text, appended after the mapped line. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ked publish case (objectui#13120) (#13168) Fixes #13120 Clause-②: no ## What changed Two objectui#12929 test files read the backfill checkbox by its counted name with a synchronous `getByRole`. Both reads now wait with `findByRole`. Each keeps exactly the same name and the same assertion, so a checkbox without the count still fails. 1. **`StudioDesignSurface.publishBackfill-12929.test.tsx`**, case "unchecked: the publish names no field, so nothing is written into existing rows". The read right after `openSheet()` is now `await within(sheet).findByRole('checkbox', { name: new RegExp(...N...) })`: the same `\bN\b` name (N = 12) and the same `not.toBeChecked()`. The other two cases already read it this way. `openSheet()` gets a doc comment saying it resolves once the option is drawn, not once its count is. 2. **`DraftChangesPanel.backfill-12929.test.tsx`**, case "one choice — the added literal-default field of the published object — unchecked, naming the record count". The read after `settled()` and `findAllByTestId('draft-backfill-option')` is now `await screen.findByRole('checkbox', { name: 'Backfill default (12 existing records)' })`, with the exact same string. The claim was amended to add this file in patch round 1. The PR also adds `.changeset/13120-backfill-checkbox-wait.md` with an empty frontmatter, so it releases nothing; its text names both files. `node scripts/check-changeset-presence.mjs` asked for it because it counts every file under a released package's `src/` as published source, test files included. No product source changed. ## Why it flaked In `DraftChangesPanel.tsx`, `EntryDetail` draws the backfill option in the same render that sets `detail` and `candidates`. The record count comes from an effect that runs after that render (`source.find(entry.name, { $top: 0 })`). Until `setRecordCount` lands, the label reads "Backfill default" with no number. Both tests waited for the option, not for its count, so on a slow runner they could read between those two renders. That read finds a checkbox named "Backfill default" and no checkbox whose name contains 12. ## Deterministic reproduction (one-shot, not left in the tree) For each leg, a script rewrote the file's count-read mock (`ADAPTER.find` in file 1, the `find` set in `beforeEach` in file 2) and checked the rewrite on disk: the anchor count went from 1 to 0 and the injected marker from 0 to 1. After each leg it restored the file from `HEAD` (`git checkout HEAD --` with an absolute path, under an EXIT/INT/TERM trap). The restore was confirmed by comparing blob hashes and by an empty `git diff HEAD`. "+200 ms" means the count resolves 200 ms after it is asked for (`setTimeout`). "Never" means it returns a promise that never settles. | File | Tree and count read | Result | | --- | --- | --- | | 1 publishBackfill | BASE `ed28057`, unmutated | exit 0, 3 passed: the flake's usual green | | 1 publishBackfill | BASE, +200 ms | exit 1, 1 failed and 2 passed: the unchecked case, `Unable to find an accessible element with the role "checkbox" and name /\b12\b/` (the same message as merge-group run 39367006901); the role dump shows `Name "Backfill default"` | | 1 publishBackfill | this branch, +200 ms | exit 0, 3 passed | | 1 publishBackfill | this branch, never | exit 1, 3 failed: each `Unable to find role="checkbox" and name /\b12\b/` once the `findByRole` window runs out (1164 to 1396 ms) | | 2 DraftChangesPanel | BASE `ed28057`, unmutated | exit 0, 10 passed | | 2 DraftChangesPanel | BASE, +200 ms | exit 1, 1 failed and 9 passed: the one-choice case, `Unable to find an accessible element with the role "checkbox" and name "Backfill default (12 existing records)"`; the role dump shows `Name "Backfill default"` | | 2 DraftChangesPanel | this branch, +200 ms | exit 0, 10 passed | | 2 DraftChangesPanel | this branch, never | exit 1, 1 failed and 9 passed: the one-choice case, `Unable to find role="checkbox" and name "Backfill default (12 existing records)"` after 1306 ms | In the "never" legs, every read of the counted name goes red, so none of them was weakened. In file 2, the other nine cases do not read the counted name and stay green. ## Every read in both files **File 1 (publishBackfill)** - **Changed:** the unchecked case's counted-checkbox read, which was the file's only synchronous read of the counted name. - **Left alone:** - The checked and stopped-short cases already used `findByRole`. - `openSheet()` reads only through waits. - `getByTestId('draft-changes-publish')` is the footer button, drawn with the sheet and not count-gated. - The request and toast assertions sit behind `waitFor`. **File 2 (DraftChangesPanel)** - **Changed:** the one-choice case's counted-checkbox read, the file's only synchronous read of the counted name. - **Left alone:** - In the same case, `options[0]` and its `= false` text come from the option and the literal span, which are drawn with the option, not with the count. The `find` call assertions sit behind `waitFor`. - "names no number when the count read carries no numeric total" uses a waiting `findByRole` on the exact name 'Backfill default'. That name is also the pre-count name, so a slow runner can only push it toward green, never red. A one-shot probe showed the case still discriminates: given a numeric total (7), it goes red, `Unable to find role="checkbox" and name "Backfill default"`, 1 failed and 9 passed. - The no-package and no-Publish cases make synchronous absence reads after `settled()`. No option is drawn and no count is read, and an absence read cannot turn red because of a slow runner. - The footer cases read `/^Backfill default/`, a prefix that matches the label with or without the count. That includes the synchronous `getByRole` after the click in "checked: Publish all passes…". With the count delayed 200 ms, all 10 cases pass on this branch, so these reads were measured as not count-gated. `getByTestId('draft-changes-publish')` is the footer button. - The `backfillCandidates` cases are pure function tests. ## Gates Head `05dc463`. Exit codes were captured before any pipe. - File 2 unmutated, three runs: exit 0 each, `Tests 10 passed (10)`. File 1 unmutated at this head: exit 0, `Tests 3 passed (3)`. - `pnpm --filter @object-ui/app-shell type-check` and `lint`: exit 0 each. They ran after `turbo run build --filter=@object-ui/app-shell^...` (exit 0, 28 of 28 tasks from cache). Lint reports `0 errors`, plus the package's existing warnings, none on either file. `eslint --max-warnings 0` on the two files: exit 0. - `pnpm check:new-line-citations`: exit 0 (`0 new citation(s)`). - `pnpm check:control-bytes`: exit 0. - `node scripts/check-changeset-presence.mjs`: exit 0 (2 source files, 1 changeset, empty frontmatter). - These also exit 0: `check:changeset-claims`, `check:pending-changeset-literals`, `changeset:check`, `check-changeset-overwrite.mjs`. - `check-governed-queue-guard.mjs --test` on all three paths: NOT GOVERNED. - The full CI farm runs in CI. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Implemented in session `https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7` (round 0 and patch round 1). --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…N rows (objectui#13156) (#13170) Fixes #13156 Clause-②: no ## What changes The Import Wizard now sends a saved mapping its rows in the format the mapping declares. A mapping that declares `sourceFormat: 'csv'` is sent `format: 'csv'`, the rows as CSV text and `mappingName`. That is the REST door's proven shape from the card: `{"format":"csv","mappingName":"crm_account_import","csv":…}` answered 200, upsert, 2/2. A mapping that declares `json`, or declares no format, still gets JSON rows. Hand-mapped imports are unchanged. Before this change, the wizard parsed every file in the browser and always sent `{"format":"json","rows":[…]}` with `mappingName`. The import door judges the declared `sourceFormat` against the payload, as it should. It answered `400 MAPPING_FORMAT_MISMATCH`, so HotCRM's shipped lead, account and contact CSV mappings imported 0 rows (objectstack-ai/hotcrm#2188, F-07). The fix follows the declaration, and nothing server-side moves. ## How All changes are in `packages/plugin-grid/src/ImportWizard.tsx`: - **`assembleImportRequest`** (the request builder; the one place every import request is built) takes a new option, `mappingSourceFormat`. On the named-mapping branch, `csv` gives `{ format: 'csv', csv, mappingName }` with no `rows`. Any other value gives `{ format: 'json', rows, mappingName }`, as before. - **`sourceRowsToCsv`** (new, pure) writes the source-keyed rows from `buildSourceRows` as RFC 4180 text: a header row, then one line per row, joined by CRLF. A cell is quoted when it holds a comma, a double quote, a CR or an LF. The door's CSV parser starts a quoted run at any double quote and drops a bare CR, so those two cases have to travel quoted. The columns come from the rows themselves, so the CSV text and the JSON rows cannot disagree. - **`buildImportRequest`** (the submit path) passes `activeMapping?.sourceFormat`, read from the mapping the server serves (`listImportMappings`, or the `savedMappings` prop). It is kept as a string, so the callback keys on the declaration and not on a memoised object. The real import, the dry-run pre-check and the background import job all go through this one builder, so all three send the CSV. The wizard's file read is unchanged. No uploaded text is kept, for the reason under H2 below. ## The PM's hypotheses, measured - **H1, single producer: confirmed.** `assembleImportRequest` was the only place with a `format` literal. Its named-mapping branch hard-coded `format: 'json'`. `SavedMapping.sourceFormat` (`'csv' | 'json' | 'xml' | 'sql'`) was already declared, and `asSavedMapping` passes it through. The `listImportMappings` wire fixture (`listImportMappings.test.ts`) carries it. The format is never inferred from the file name. - **H2, which text goes out: the preview's rows, written as CSV.** On the mapping path, the preview step renders an editable `Input` for every mapped column (`StepPreview`, through `handleCorrect`), and `buildSourceRows(headers, rows, corrections)` applies those corrections. Sending the file as uploaded would silently drop them. It would also be wrong for three other inputs: a `.tsv` file (the door's CSV parser splits on commas only), a pasted table and an `.xlsx` sheet (neither has CSV text at all). The CSV is therefore written from the preview's rows, with corrections applied. A pin corrects a cell and asserts that the CSV carries the correction. - **H3, `.xlsx` against a csv mapping.** Before the fix, the wizard parsed the sheet in the browser and sent JSON rows with `mappingName`. The ablation leg reads this as `format: "json"` plus `rows`, which the door refuses as above. Now the sheet's cells go out as CSV text, exactly as the preview shows them. A pin builds a real workbook with ExcelJS, passes it as `initialFile`, and asserts `format: 'csv'` with the sheet's text and no `rows`. The door's own comment backs this outcome: "xlsx rows are tabular like csv; a csv-declared mapping applies to both". - **H4, preselection and the manual path's insert: measured, not fixed.** They are a different producer from the request builder, so they stay out of this PR. See Acceptance notes. - **H5, request-shape pins plus ablation: done.** See Verification. ## Verification (final head `9ff6405`) Every heavy run went through the shared verify lock. - **Targeted wizard suites.** `pnpm exec vitest run --maxWorkers=2` over all 26 import test files in `packages/plugin-grid/src` (`*mport*.test.ts(x)`, `__tests__/*mport*.test.tsx`, `savedMapping.test.ts`) gave `Test Files 26 passed (26)` and `Tests 245 passed (245)`, exit 0. - **New pins.** - `ImportWizard.mappingSourceFormat-13156.test.tsx` drives the real wizard to `importRecords`. A csv mapping with a corrected cell gets `format: 'csv'`, CSV text and `mappingName`, and the dry-run pre-check sends the same payload plus `dryRun: true`. An `.xlsx` sheet against the csv mapping is sent as CSV text. CONTROL: a json mapping still gets JSON rows, corrections applied. - `importSavedMapping.test.ts` gains the builder's branch table (csv, json, undeclared, and the manual path ignoring a declared format) and `sourceRowsToCsv`'s quoting cases. - **Ablation, run at `9341d6d` and again at `9ff6405`.** `node ../objectstack/scripts/ablation-replace.mjs` replaced the decision `opts.mappingSourceFormat === 'csv'` with `false`, so every named mapping got JSON rows again. The anchor went from 1 hit to 0 and the blob changed, both checked on disk. - The two pin files then read `Tests 3 failed | 10 passed (13)`. The failures were exactly the three csv-mapping cases: the builder branch, the corrected-cell CSV and the `.xlsx` sheet. The JSON control and the quoting cases stayed green. That is the expected direction: red. - The restore was proven: the blob equals `HEAD`, and `git diff HEAD` and `git status --porcelain` are both empty. - **The door's own parser.** I ran objectstack's `parseCsvToRows`, taken verbatim from `packages/rest/src/import-prepare.ts` at `580fd4ed3` and transpiled, over the five CSV texts the pins fix as `sourceRowsToCsv` output. All five parse into exactly the JSON rows the old request carried. CONTROL: the same values sent unquoted, an inner double quote and a bare CR, both parse differently. This is a source-level measurement, not a live door. No backend was booted. - **Static checks.** - `pnpm --filter @object-ui/plugin-grid lint`: exit 0, 0 errors. The new test file has no entries. - `pnpm --filter @object-ui/plugin-grid type-check`: exit 0, 0 errors. It ran after building the dependency closure with `turbo run build --filter='@object-ui/plugin-grid^...'` (13 of 13 tasks). `tsc -p tsconfig.test.json --listFilesOnly` lists the wizard and both test files. - **The other consumer of the request.** The request shape is what `importRecords` receives, and app-shell's identity-import wrapper is the other host that reads it. Its four suites pass (4 files, 29 tests); see Acceptance notes. - **Gates.** `pnpm check:new-line-citations` (0 new), `pnpm check:control-bytes`, `pnpm check:changeset-claims`, `node scripts/check-changeset-presence.mjs` and `node scripts/check-changeset-no-major.mjs` all exit 0. No user-facing string changed, so the i18n gates were not run. ## Acceptance notes - **Identity import (`sys_user`) with a csv-declared saved mapping still imports nothing. The refusal now happens in the browser.** app-shell's `createIdentityImportDataSource` wraps this wizard for `sys_user`, and it offers saved mappings (objectui#7740). Its `importRecords` reads `request.rows` only, splits them into batches, and throws "Identity import needs mapped rows (rows[])" when there are none. - Measured with a probe over the real wrapper, with `authFetch` stubbed: - JSON rows plus `mappingName` reach the door, 1 request. Before this fix, the door refused that request for a csv mapping, either with `400 MAPPING_FORMAT_MISMATCH` or, without a meta reader, with `MAPPING_NOT_FOUND`. Both come from `prepareImportRequest` in the identity door. This half is a source reading of `plugin-auth`'s `admin-import-users.ts` at objectstack `580fd4ed3`, not a measurement. - CSV text plus `mappingName` makes the wrapper throw, with 0 requests sent. - So the outcome is unchanged (0 rows imported on either side of this PR), but the refusal is now client-side. - The real fix is to make the wrapper forward `format: 'csv'` text. That change is in `packages/app-shell/src/views/identityImport.ts`, outside this claim's file surface, so it is not made here. It is raised to the seat. - I found no shipped mapping that targets `sys_user`. objectstack's examples ship one mapping, the showcase's `showcase_inquiry_feed`, which declares `csv` and targets `showcase_inquiry`; that mapping is on the generic door this PR fixes. HotCRM was not checked. - The four app-shell identity-import suites (`packages/app-shell/src/views/__tests__/identityImport*`) pass at `9ff6405`: `Test Files 4 passed (4)`, `Tests 29 passed (29)`. None of them submits through a mapping, so none of them reaches the changed request. - **The shipped mapping is offered but not preselected (H4).** It is measured. A saved mapping becomes active only through an explicit pick (`handleSelectSavedMapping`). Nothing selects one when a file loads, and the reset clears it. This is a product choice, not a contract breach: no declaration says a mapping is preselected, and choosing one among several is a design question. Its producer is the mapping step's selection state, not the request builder. Not changed. Carrier: none. - **Manual mapping inserts and rejects the synonym values (H4).** It is measured. The manual path starts in `insert` (`defaultWriteMode`), and the user can choose upsert in the options panel. A mapping's value synonyms (`transform: 'map'` with `valueMap`) are applied only by the server's named-mapping path, by design. With this fix, the named-mapping path is the one that works for a csv mapping. Not changed. Carrier: none. - **Mappings declaring `xml` or `sql`** are still offered in the selector, and the door refuses them with `400 MAPPING_FORMAT_UNSUPPORTED` and a clear message. Nothing measured produces such a mapping for the console. Unchanged. Carrier: none. - **A mapping that declares no format** keeps JSON rows. Served mapping documents carry the spec's default (`sourceFormat` defaults to `csv`, and the `listImportMappings` wire fixture shows it filled), and the door treats an undeclared format as compatible with either payload. So this branch only keeps today's behaviour for a document that lacks the key. - **Edge case of the CSV form.** If the user blanks every cell of the LAST row, that row is not sent. The door's CSV parser drops trailing all-empty rows, while the old JSON rows sent it as an empty record. Parsed files never contain all-empty rows (every wizard parser drops them), so only that hand-edit reaches this. - objectui#13124 (the finished step's subtitle) shares this file and runs after this card, as the claim records. - `test:repo` and the full package suite were not run locally; both belong to CI. ## Files - `packages/plugin-grid/src/ImportWizard.tsx`: +45 / −7 - `packages/plugin-grid/src/ImportWizard.mappingSourceFormat-13156.test.tsx`: new, 206 lines - `packages/plugin-grid/src/importSavedMapping.test.ts`: +73 - `.changeset/13156-import-mapping-csv.md`: `@object-ui/plugin-grid` patch Session: `https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7` --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…Fields Filter-panel whitelist reader (objectui#13118) (#13169) Fixes #13118 Clause-②: no (narrowing) ## What `@objectstack/spec` 18 retires the list view's `filterableFields` (#24640, maintainer ruling 「C」, record 6278975049; pulled into 18.0 by 「提前」, record 6279033298). No per-view Filter-panel whitelist replaces it: the Filter panel offers every field the user may read, minus the fields the object marks `hidden: true`. This PR removes objectui's readers of the key. - **Renderer** (`@object-ui/plugin-list`). The `filterFields` memo in `ListView` drops the whitelist branch and the `!!whitelist` exemption for hidden fields, and the memo's deps drop `schema.filterableFields`. The no-whitelist branch already offered every readable field minus `hidden` ones, so a view without the key sees no change. A hidden field still appears when a condition the panel holds filters on it (objectui#11810's rule, unchanged). - **Relays**. The `object-view` delegation in `@object-ui/plugin-view` stops forwarding the key from all three sources (named view, host `views` entry, node), and `OBJECT_VIEW_DECLARED_FORWARDED_KEYS` drops it (31 node reads become 30: 27 host-composition plus 3 declared). The `@object-ui/app-shell` object page stops relaying it off the active view. The `object-view` registration stops publishing it as an input. - **Types** (`@object-ui/types`). `NamedListView.filterableFields` is a `?: never` tombstone. `ObjectViewSchema.filterableFields` is deprecated and typed by reference to the list view's spec-derived slot, the slot its zod twin already carries; see H2 for why it is not a tombstone yet. - **Zod mirror**. The by-reference line `filterableFields: stripImportedDefaults(SpecListViewSchema).shape.filterableFields` stays, as the card prescribes; only its comments changed (H3). - **Docs**. The `ObjectViewSchema` example in `content/docs/api/schema-reference.md` stops teaching the key; `content/docs/plugins/plugin-view.mdx` and `packages/plugin-view/README.md` stop listing it. - **Changeset**. `.changeset/13118-retire-filterable-fields.md`: `minor` for `plugin-list`, `plugin-view`, `types`, `app-shell`, with the BREAKING paragraph and its remedy (delete the key; for a curated end-user filter bar, author `userFilters`). The `Clause-②` line above is the claim's, and the delivered diff still matches it: it accepts nothing new and narrows the published surface (the registration input, the `NamedListView` member, the renderer behaviour). ## The PM's hypotheses, measured **H1 — confirmed.** The renderer change is the card's own. Tests that asserted the whitelist now assert the spec's prescription; none was deleted. - No-key behaviour does not move: the objectui#11810 row "offers no hidden field, and orders columns, other business fields, system fields" is unchanged and green at the head, and green with the whitelist branch put back (the ablation below runs BASE's memo logic). - The removal is pinned by four rows that a stored view still carrying the key exercises: `ListView.filterFieldList-11810` (the spec's list, no hidden field brought back), `ListView.filterFieldRead-11925` (the read-checked list, no narrower and no wider), `ListView.sortFieldFallback` (the inverted control: the filter builder offers every field) and `ListViewBlock.specInputs-13047` (through the real `SchemaRenderer` page node: "Add filter" starts on `Name` with or without the key). - Ablation, one shot, committed fix first: `ablation-replace.mjs` put the whitelist branch back into the memo's filter by a literal anchor (anchor 1 to 0, marker 0 to 1 on disk, blob changed), ran the four files, and restored (blob equals HEAD, `git diff HEAD` empty). Result: exactly the four rows red, 66 green. The failures read: `expected [ 'title', 'estimate_hours', …(1) ] to not include 'owning_business_unit_id'`; `expected [ 'title', 'priority' ] to deeply equal [ 'title', 'status', 'priority' ]`; `Unable to find an element with the text: Name`; `expected 'Amount' to be 'Name'`. No permanent ablation file was left. **H2 — measured per parity test, at objectui's pin `f4755a68`.** Every one stays, none is skipped. - `object-view-unmirrored-keys-7779`: models the key with the file's own mechanism, a named bucket pinned exactly, after objectui#12030's PENDING precedent. A sixth bucket, `BUCKET_SPEC_RETIRED_AHEAD_OF_PIN = ['filterableFields']`, holds a key the installed protocol still calls live and objectstack `main` retires. It is self-expiring: its row asserts the installed spec does not carry the tombstone yet, so the pin bump reddens it with the instruction to move the key into `PROTOCOL_RETIRED_KEYS` and `BUCKET_PROTOCOL_RETIRED_TOMBSTONED`. The node-level read pins for the key are inverted, not deleted; the named-view census moves from 43 read plus 22 unread to 42 plus 23 (65 declared, unchanged). - `registry-inputs-spec-parity` (console): the existing `UNPUBLISHED_EXEMPTIONS` entry for `list-view.filterableFields` keeps its slot with a true reason; it goes stale on its own at the pin that carries the tombstone. - `object-view-spec-parity`: green unchanged in mechanics (TS and zod both still declare the key); its `SPEC_COUNTERPART` note is updated. - `zod-mirror-parity`: green with no change, because of the next point. -⚠️ **Deviation, reported:** a local `?: never` tombstone on `ObjectViewSchema` was tried first and measured red. `node-recursion-point-8344` (types `type-check`, TS2344) reads every zod arm's output against the TS node union, and the `object-view` arm became a new drifted arm against the by-reference zod slot, which still admits `string[]` at the pin. That ratchet says not to add a name to make a red go away, so the node's member is typed by reference to the same spec slot instead: both faces flip to the spec's tombstone at the pin bump, with nothing to edit there. `NamedListView` has no zod twin, so its tombstone holds now. **H3 — confirmed.** The zod line stays. Under objectstack `main`'s spec (the H4 green leg below), `@object-ui/types` builds, so leaving the by-reference line compiles there. **H4 — reproduced, red then green.** In a scratch objectstack worktree at `origin/main` `580fd4ed3c98` (`git merge-base --is-ancestor 4bc3bec HEAD` answered 0), after `pnpm run setup`, with `.objectui-sha` edited locally and never committed, `OBJECTUI_ROOT=/home/user/objectui-issue-13118 bash scripts/build-console.sh` under the verify lock: - at BASE `ed2805768b32`: exit 1, `@object-ui/plugin-list#build` failed with exactly two errors in `src/ListView.tsx`, TS2339 (`Property 'length' does not exist on type` the spec's `[REMOVED]` brand) and TS2769 (no `Set` constructor overload matches it); - at this head `05d46fed0a2b`: exit 0. The bundle canary, the single-zod canary and the spec-injection assertion all pass; the dist is ready. The script's notice that the tracked `sdui.manifest.json` records a different objectui SHA is the objectstack pin bump's to regenerate. - The scratch worktree and the two objectui build worktrees the script registered were removed afterwards (node_modules first, `git worktree remove` without `--force`, both clean). ## Gates, at head `05d46fe` - `type-check` for `types`, `plugin-list`, `plugin-view`, `app-shell` and `console`: exit 0 at `416ec6c`, after building the console's dependency closure at objectui's pin (`turbo run build --filter=@object-ui/console^...`, 34 tasks). A per-package `type-check` without that closure answers TS2307 for every sibling package: not measured, not red. The one later commit, `05d46fe`, restores two blank lines in `objectql.ts`; `types` and `plugin-list` `type-check` were re-run on it, exit 0. - `lint` for the same five packages: exit 0, 0 errors, at `416ec6c`. Per changed file, the eslint findings at the head equal BASE's count or fall below it. - Targeted tests, 18 files (the touched test files, the four parity tests, `node-recursion-point-8344`), re-run on `05d46fe`: 18 files, 937 tests passed. - `pnpm check:new-line-citations` (0 new), `pnpm check:control-bytes`, `pnpm check:changeset-claims`, `node scripts/check-changeset-presence.mjs`, `node scripts/check-changeset-no-major.mjs`: exit 0 each. - Full package suites and the rest of the farm are CI's. ## Acceptance notes - **Shared files.** `domain:ui#2` holds objectui#13116 in `ListView.tsx` (the `$select` harvest) and objectui#13114 in the app-shell `ObjectView.tsx` (the tab bar and the chart branch). This diff stays in the `filterFields` memo and its docblocks, and in the one relay line of the app-shell file. Whichever lands second merges `main`. - **Test files beyond the claim's census.** Two files that never spelled the key had to move with the reader: `ObjectView.relayRungCensus-7559` (a new `unread` answer, the `striped` precedent) and, per its own instruction to delete a stale entry in the same change, `InterfaceListPage.relayCensus-11572`. `node-recursion-point-8344` is unchanged and was measured. - **Left alone on purpose.** `SchemaForm.topLevelUnion-11251` (the spec's own view form at the pin still offers the key), `objectView-config-keys-retired-4730` (a locale-key record), `ObjectView.specShapedNamedView-8254` (history), `ROADMAP.md` and `docs/audits/**` (dated records). All green or untouched. - **What the pin bump that takes `4bc3becd5f` will meet.** Each announces itself by name: the self-expiring bucket in `object-view-unmirrored-keys-7779`; the `SPEC_COUNTERPART` anchor in `object-view-spec-parity`; the console's `list-view.filterableFields` exemption going stale; and the `ObjectViewSchema` member, which narrows with no edit because it is typed by reference. - **Observation, not filed (no carrier).** objectstack's `scripts/build-console.sh` checks `OBJECTUI_ROOT` with a directory test on `.git`. A linked worktree's `.git` is a file, so the override is skipped and the script falls back to the sibling `../objectui` checkout ("Using objectui source at /home/user/objectui" in both legs above). Here the two share one object store, so the SHA resolved and both readings stand. --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ry (#13172) Fixes #13122 Clause-②: no ## What The console's app-missing branch in `packages/app-shell/src/console/AppContent.tsx` (`requestedAppMissing`) draws three verdict screens — `app-not-found` ("This app can't be opened"), `app-unreachable` and `app-not-available`. All three return above the `ConsoleLayout` mount, so they carry no app bar, and they offered only Retry: a stale bookmark or an uninstalled app's link was a full-screen dead end. Each of the three now draws "Back to home" beside Retry. The button is the access-denied screen's own, factored into one render helper (`renderBackHomeButton`) that both screens call: same copy key (`empty.appAccessDeniedHome`), same navigation (`navigate(homePath)`, the declared home). Test ids follow the existing pattern: `app-not-found-home`, `app-unreachable-home`, `app-not-available-home`. The access-denied screen keeps its `app-access-denied-home` test id and renders the identical button. No new i18n key, no export, no chrome change. ## Pins (in `AppContent.deniedVsUnpublished.test.tsx`) - (a) `the $testId screen offers the way home beside Retry, and it goes home` — one case per verdict: the home button is drawn with the text "Back to home", Retry is drawn beside it, clicking home lands on `/home`. - (b) CONTROL `Retry on the $testId screen is still drawn and still re-checks` — one case per verdict: clicking Retry calls `refreshMetadata` again and the verdict screen comes back. Written independently of the home button on purpose. - (b) the access-denied screen is unchanged: the existing cases `the denial screen offers a way back to /home` and the declared-landing case stay green. ## Ablation (one-off, no permanent file) Fix committed first; mutation through `ablation-replace.mjs` deleting the `renderBackHomeButton(...-home)` line from the missing branch only (anchor 1 to 0, blob 9343ae5 to bcd2e851e811), trap-guarded restore: - mutated: `Tests 3 failed | 22 passed (25)` — exactly the three (a) cases red; the three Retry CONTROLs and the denied-screen home case green. - restored: blob after restore == HEAD blob `9343ae560f748d669af602aed68ce6e9649104b2`, `git diff HEAD` empty. - green on HEAD `ada1ba4e01`: `Test Files 1 passed (1)`, `Tests 25 passed (25)`. ## Gates (local, at `ada1ba4e01`) - `@object-ui/app-shell` closure build (`--filter '@object-ui/app-shell^...'`, 29 projects) exit 0, then `pnpm --filter @object-ui/app-shell type-check` (echoes `tsc --noEmit && tsc -p tsconfig.test.json`; the test config includes `src/**/*.test.tsx`) exit 0 — under the shared verify lock. - Targeted vitest on the edited test file, `--maxWorkers=1`: 25/25. Declared narrowing: this one-file, one-worker run (and the ablation) ran outside the shared lock after about 30 minutes queued behind a sibling full-directory run. - eslint narrowed to the two changed files: 0 errors (warnings are pre-existing lines). Population read from the root `eslint.config.js`; `--format json` counted 2 files; that config enables no type-aware linting (no `projectService` / `parserOptions.project`), so this diff cannot move a verdict on any untouched file. - Pre-tsc battery all exit 0: `check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`, `control-bytes`, `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`, `check-changeset-presence.mjs`. - `check:spec-floors` exit 1 — recorded as known on main per dispatch, not touched. - Declared to CI: the full `packages/app-shell/` directory run (a sibling's full-directory run held the shared lock for over 35 minutes at one worker; it does not fit the foreground cap). ## Acceptance notes - The reused copy key is named for the access-denied screen (`empty.appAccessDeniedHome`). Reused as ruled rather than renamed; a rename would touch all ten packs for no user-visible change. - Changeset: `.changeset/13122-app-missing-way-home.md`, `@object-ui/app-shell` patch. Session: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB` --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #13114 Clause-②: no ## What Opening a `type: chart` saved view on the console list page removed the view tab bar. Since objectui#12440 the strip is handed to `ListView` as `toolbarLeading`, and `renderListView`'s chart branch (`viewDef.type === 'chart'`) returns before `ListView` is reached, so a chart view drew no tabs and the only way back to the grid was the browser's Back button. Ruling 1 route taken: the chart branch draws the strip itself, above the chart. The row that a server-refused view already used is lifted into one element, `viewTabRow` (same classes and `data-testid="view-tab-row"`, gated on `viewTabBar`, so on `viewTabsFit` too: below `sm` it is absent and the app bar's switcher stays the phone's control). Both chart returns (dataset-bound and unbound) wrap their `Suspense` face with it. List faces keep the strip in `ListView`'s toolbar row, unchanged; no face draws two. Ruling 2: the page-level refused-view fallback now reads `refusedViewFace && viewTabRow`, which is the same markup as before. Files: `packages/app-shell/src/views/ObjectView.tsx`, a new pin `packages/app-shell/src/views/ObjectView.chartViewTabs-13114.test.tsx`, and `.changeset/13114-chart-view-tabs.md` (`@object-ui/app-shell` patch). No export, prop or type member is added. ## Pins The pins drive the real `ObjectView` → plugin `ObjectView` → `renderListView` → `ListView` / `ObjectChart` chain. - (a) A chart view draws exactly one `view-tab-bar`, inside `view-tab-row`, with no `list-toolbar-leading`. Clicking the grid tab switches back to the grid, where the one strip sits in the list toolbar slot. - (b) CONTROL: a grid view has exactly one strip, in `list-toolbar-leading`, and no standalone row. - (b) CONTROL: below `sm` a chart view draws no strip. - Refused-face CONTROL: the existing `ObjectView.viewRefusal-12112.test.tsx` passes unchanged. Its `getByTestId('view-tab-bar')` throws on more than one match. ## Verification (head `e0ea6dbd93`) - Closure build `pnpm --filter '@object-ui/app-shell^...' build`: exit 0. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit` plus `tsc -p tsconfig.test.json`): exit 0. `--listFilesOnly` on the test project lists the new pin. - Targeted vitest (13114 pin, 12440 toolbar pin, 12112 refusal pin): 3 files, 12 tests passed. - Family run: 17 files, 766 tests passed. It covers the ObjectView chart and tab tests, and every test that reads `ObjectView.tsx` source. - Ablation via `ablation-replace.mjs`. The anchor `{viewTabRow}` in the chart wrapper (hits 1 to 0) was replaced with `{null}`, and the blob went from `bb7a2825d46c` to `f79650798867`. Pin (a) went red with `Unable to find an element by: [data-testid="view-tab-row"]`, 1 failed and 2 passed. The two controls stayed green. Restore: the blob equals the HEAD blob `bb7a2825d46c` and `git diff HEAD` is empty. - Lint narrowed to the two changed files with `eslint --format json`: 2 files, 0 errors. The 183 warnings are identical to `origin/main`'s copy of `ObjectView.tsx`, where the same 183 are pre-existing. The config has no `parserOptions.project` or `projectService` (not type-aware), so this diff cannot move any untouched file's verdict. - Gate battery: exit 0 for phantom-deps, self-import, unreferenced-sources, doc-example-readers, handler-key-reads, side-effects-array, element-data-source-declaration, spec-symbols, action-forward-parity, designer-field-key-parity, icon-record-names, i18n-keys, i18n-drift, i18n-designer-parity, control-bytes, new-line-citations, `check-hand-rolled-comment-mask`, `check-changeset-fixed`, `check-changeset-presence` and `check-changeset-no-major`. - NOT MEASURED: `check:spec-floors`, reason: prerequisite not met (no-artifact, it wants a full workspace build). The order said to record it, not fix it. - NOT MEASURED: the whole `packages/app-shell/` directory, reason: a `--maxWorkers=1` run under the shared lock printed no file result in 45 minutes. I stopped it to release the lock to queued siblings. It is declared to CI. ## Acceptance notes None. Session: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB` --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ Co-authored-by: Claude <noreply@anthropic.com>
…lish (objectui#13160) (#13174) Fixes #13160 Clause-②: no ## What changed The package sheet's **Publish** (`PackageDetailSheet`'s `publish` in `packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`) now shows the reasons the server returned, under the existing count sentence. `POST /packages/:id/publish` refuses on a 200: the dispatcher's `{ success: true, data }` envelope around the door's `PackagePublishResult`, whose own `success` is false and whose `validationErrors` carry one `message` per reason, each against the item (`type`, `name`) it judged. The sheet threw only `engine.packages.detail.publishBlocked` with the count, so the author read "Publish blocked by 1 validation error(s)." and never why (the card's measurement: a package with no items, refused with "No metadata items found for package …"). - The count sentence stays as the heading. Each `validationErrors[].message` is a list line under it, in the server's words, led by the item it names when the server names one (`object/lead: …`; `api: …` where the producer names only the type; the bare message where it names neither). Nothing restates the protocol's rule. - The answer is typed with the spec's `PackagePublishResult` (`@objectstack/spec/system`) instead of `any`. - `success: false` with an empty `validationErrors` still says `engine.packages.detail.nothingToPublish`. The old fallback there read `validationErrors[0].message` only when the list was empty, so it could never fire; it is gone. - The message block (`msg`) carries an optional `details` list; only this refusal sets it. Every other action on the sheet renders exactly as before. - No i18n key is added or changed; no export, prop or accept set moves. ## Mechanism readings (the dispatch's H1 to H4) - **H1, the shape: confirmed, measured on objectstack `origin/main` `46bb5e3e5`.** The route (`packages/runtime/src/domains/packages.ts`, the `POST /packages/:id/publish` branch) answers `deps.success(result)` with `MetadataManager.publishPackage`'s result. Its two refusals both carry a non-empty `validationErrors` of `{ type, name, message }` (`PackagePublishResultSchema` in `packages/spec/src/system/metadata-persistence.zod.ts`): no items (`type` and `name` both empty), and the gate or validation failures (the namespace gate leaves `name` empty with `type: 'api'`). No other field or path is in the shape. - **H2, many errors: no cap.** `SheetContent` is `overflow-y-auto`, so a long list extends the sheet's scroll height, as the uncapped pending-drafts list on the same sheet already does; `break-words` keeps a long item id inside the sheet's width. NOT MEASURED in a browser: the reading is the sheet's own class list, not a rendered layout. - **H3, controls: pinned.** A published package keeps `engine.packages.detail.published`; an empty `validationErrors` keeps `nothingToPublish`; a request that throws (`TypeError: Failed to fetch`) shows its own message. None of the three renders a list. - **H4, ablation: red with the fix reverted** (below). ## Tests New: `packages/app-shell/src/views/metadata-admin/PackagesPage.publishRefusalReasons-13160.test.tsx` (real sheet and `apiJson`; `fetch` stubbed). Two refusal cases (one item-less reason; two reasons, one with `type/name`, one with `type` only) and the three H3 controls. Ablation, after committing the fix (`bedd292`): `PackagesPage.tsx` written back to the base blob (`98ee995`, `PackagePublishRefusal` count 0, the old count-only branch count 1), new file run, then restored with `git checkout HEAD --` and checked by blob hash (`431a3fa` equals the HEAD blob, `git diff HEAD` empty): ```text × one reason that names no item: the measured refusal of a package with no items × two reasons: each is a line, led by the item it names, in the order the server sent them AssertionError: expected [] to deeply equal [ Array(1) ] (the heading rendered, no lines under it) Tests 2 failed | 3 passed (5) ``` The three controls stay green on both sides, as they should. ## Local gates, on head `5178919` Each exit code captured to a file before any pipe; the heavy three ran under the shared verify lock after the final commit. | Command | Exit | Its own verdict line | |---|---|---| | `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit` and `tsconfig.test.json`, which includes `src/**/*.test.tsx`) | 0 | no `error TS` | | `pnpm --filter @object-ui/app-shell lint` | 0 | `0 errors` (warnings are the package's existing ones) | | `pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/views/metadata-admin/PackagesPage` | 0 | `Test Files 10 passed (10)`, `Tests 81 passed (81)` | | `pnpm check:new-line-citations` | 0 | `VERDICT new-cross-file-line-citations: 0 new citation(s)` | | `pnpm check:control-bytes` | 0 | `check-control-bytes: OK` | | `pnpm check:changeset-claims` | 0 | `No pending changeset names a file this change touches.` | | `node scripts/check-changeset-presence.mjs` | 0 | `2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)` | | `node scripts/check-changeset-no-major.mjs` | 0 | `Every major is the synchronized one` | | `pnpm check:i18n-keys` | 0 | every in-scope call-site key resolves (run although no i18n text changed; `check:i18n-drift` not run, no string-table row moved) | The dependency closure was built first (`pnpm turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`, exit 0), so the type-check read current `dist/*.d.ts`. Declared to CI: the full `pnpm test` shards, `Build & E2E`, and every other workflow family. ## Acceptance notes - `/publish-drafts` (`publishDrafts`, the other door on this sheet) was read for the same defect and does not have it: since objectui#11922 it names each refused item with its reason through `refusedItemsText`, and its empty-`failed[]` refusal reads the envelope's own text. No finding. - "Adopt loose items" is not touched: the card leaves that half unfiled and unverified. - objectui#13157 (queued, same file) is not folded, per the claim. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Session: `https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7` --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ectui#13128) (#13176) Fixes #13128 Clause-②: no ## What changed `CreateItemDialog`, the shared form behind every Studio create dialog, rendered its display-name and identifier labels with no `htmlFor`, and its two inputs had no `id`. A screen reader therefore heard two unlabeled edit fields, and `getByLabelText('Object name')` found nothing. Two `React.useId()` ids now bind each label to its input, unique per mount. There is no visual, copy, export or prop change. The component is shared by five call sites in `StudioDesignSurface.tsx`, so all five dialogs get the binding: Create app, the Interfaces create dialog (report, page or dashboard), New object, New automation and New permission set. The seat ruled the file surface onto `CreateItemDialog.tsx` in comment 6285011413. The New object dialog's record-sharing picker is already named by the label element that wraps it, so it is unchanged. ## Pin `CreateItemDialog.labels-13128.test.tsx` drives two real openers: - New object, through the real `DataPillar` empty state; - Create app, a sibling dialog, through the real `StudioDesignSurface` toolbar. In each dialog, `getByLabelText` for the name label and for "Identifier" must find an `INPUT` with that accessible name. Label strings are read by catalog key. ## Ablation (one-shot, run at 1efc5df41a) Run with objectstack's `scripts/ablation-replace.mjs`, which replaces the `Label htmlFor={labelInputId}` opening tag with a bare `Label` opening tag in `CreateItemDialog.tsx`, then reruns the pin: - anchor x1 to x0; blob 8e7320e to a9b1abd818f1 (the mutation landed); - `Tests 2 failed (2)`. Both legs go red because both dialogs share the component: "Found a label with the text of: Object name, however no form control was found associated to that label"; - restore: the blob after restore, 8e7320e, equals the HEAD blob of the file, and `git status` is clean. ## Verification (at af6b19e935) - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build`: exit 0. - `pnpm --filter @object-ui/app-shell type-check`: exit 0. Its `tsconfig.test.json` includes `src/**/*.test.tsx`, so the new test is type-checked too. - Targeted vitest, `--maxWorkers=1`, over the new pin plus every test file that renders `CreateItemDialog`: oneLabel-11787, storedName-11792, createAppAccessibleName, createObjectOwd, flowPresets-11861, newFlowTrigger-11788. Result: `Test Files 7 passed (7)`, `Tests 34 passed (34)`. - The full `packages/app-shell/` directory is declared to CI. The order forbids whole-package runs on this shared box. - Lint narrowed to the two changed `.tsx` files. Running `eslint --no-inline-config --format json` counted 2 files, 0 errors and 6 warnings. Five warnings are `no-explicit-any` in the test's mocks, the same shape its sibling suites use. One is the existing `react-hooks/set-state-in-effect`, which origin/main's copy of the file also reports. Both files resolve in the root `eslint.config.js`. The config sets no `parserOptions.project` or `projectService`, so type-aware linting is off and this diff cannot change the lint verdict on any untouched file. - Battery: `check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity` and `control-bytes` each exited 0. So did `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs` and `check-changeset-presence.mjs`. - `check:spec-floors`: NOT MEASURED. It exited 1 with no-artifact because it needs a full workspace build, so its gate body never ran. ## Acceptance notes - The changeset is `.changeset/13128-new-object-labels.md`, an `@object-ui/app-shell` patch that names the five dialogs. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…en the server refuses the run (#13177) Fixes #13161 Clause-②: yes An action's param dialog now keeps what the admin typed when the server refuses the run. It stays open and busy until the run settles, closes on success, and on a refusal keeps every value and shows the refusal above the buttons, so the admin can fix the input and confirm again, or cancel. ## What changed - **`@object-ui/core`**: `ParamCollectionHandler` gains an optional fourth argument, `run`. Called with the collected values, it dispatches the action and resolves with that run's `ActionResult` (a dispatch that throws resolves as a refused run, as `execute` reports one). - A refused attempt gets its post-execution (error toast, `onFailure`) at once. - A successful attempt's post-execution (success toast, result dialog, chain, `onSuccess`) waits until the handler settles, so the dialog is closed before any of it shows. - A `run` call while one is in flight returns that run and dispatches nothing new. - Once `run` was called, the handler's own answer only ends the session. `null` after a refusal is a cancel. - A handler that never calls `run` keeps the old contract: one dispatch with the values it resolves. - `execute`'s dispatch tail (update operation, registered handler, builtin, legacy fallbacks) moved unchanged into the private `dispatchCollected`, so the lone run and the session share it. - **`@object-ui/app-shell`**: `ActionParamDialog` runs the action from inside the dialog when its state carries `run`. - While the run is in flight, Confirm (spinner, `aria-busy`) and Cancel are disabled and the dialog refuses to close. - On success it settles with the values that ran, then closes. - On a refusal it keeps the values and shows the run's own `error` in a `role="alert"` block above the footer, outside the scrolling region. - Both runner-fed hosts forward `run`: `useConsoleActionRuntime` and `RecordDetailView`. ## The PM hypotheses, as measured - **H1, holds.** `handleSubmit` called `state.resolve` and then `onOpenChange(false)`. The runner's `execute` awaited the handler, then dispatched and post-executed, so the refusal toast came after the dialog had closed. The runner is the only caller of the param handler (one call site, in `execute`). Every console param dialog renders this one component, mounted three times: - `useConsoleActionRuntime`, feeding the runner for ConsoleShell, ObjectView, PageView and `DeclaredActionsBar`; - `RecordDetailView`, which feeds the runner from its own runtime; - `MetadataTypeActions`, which has no runner and runs its own fetch. - **H2, shape (a), through the runner.** A fix inside `@object-ui/app-shell` alone was measured unsound, for two reasons: - The glue could correlate its own `api`, `flow`, `script` and `modal` handlers with the dialog through the dispatch object. But an `operation: 'update'` action (`executeUpdateOperation`) and the builtin executors never reach a glue handler, so their dialog would hang open. - After a refusal the glue cannot re-dispatch, because `execute` has already returned. Shape (b), a runner loop that re-opens the dialog, would have to be opt-in: the in-repo handler doubles that resolve fixed values (`ActionRunner.declaredPatch-13022`, the `action-*` suites in components) would loop forever on a persistent refusal. That opt-in would itself be a type change, and the dialog would flicker closed and back open. So the narrowest widening is the one optional `run` argument. - **H3, measured in the producers' source: none of the three refusals names a field.** - "Either email or phoneNumber is required": the objectstack plugin-auth `badRequest` sends `{ success: false, error: { code: 'INVALID_REQUEST', message } }`. - "User already exists. Use another email.": better-auth's `APIError.from` sends `{ message, code }`. - The redirect-URL refusal: better-call's endpoint rethrows its `ValidationError` as `APIError(400, { message, code: 'VALIDATION_ERROR' })` and drops the zod `issues`, so the path survives only inside the sentence. Every console handler also flattens a refusal to `ActionResult.error` (text). So the dialog marks no field. The refusal is the form's, and a field named inside the sentence is not read (pinned with the redirect-URL sentence). The wire was not re-measured in a browser. - **H4, holds.** Cancel after a refusal settles `null`, closes and dispatches nothing more (the dialog pin, and the runner pin: one dispatch, one toast). Success closes, then toasts, exactly as before (the runner pin orders the toasts `error`, `success`, and the console pin ends on the success toast). - **H5.** The ablation is below. ## Published face Clause-② is `yes`, measured. The only face that moves is `@object-ui/core`'s `dist/actions/ActionRunner.d.ts`, read after the closure build: - the `ParamCollectionHandler` line gains `run?`, a function from the values (a Record of string to any) to a Promise of `ActionResult` (generics spelled out in words here because GitHub strips them from PR text); - the `ActionRunner` class gains `private dispatchCollected;`. `@object-ui/react`'s `ActionProviderProps.onParamCollection` imports the type by name, so its bytes do not change. `ParamDialogState` gains an optional `run`, but that type is not in `@object-ui/app-shell`'s barrel. The changeset declares `@object-ui/core` minor and `@object-ui/app-shell` patch, the precedent objectui#12967 set for the third argument. ## Files beyond the claimed surface (in-place, same defect class) - `packages/app-shell/src/views/RecordDetailView.tsx`: the second runner-fed host. Its handler forwards `run` the same way, or record-page dialogs would keep closing. `RecordDetailView.paramRuntimeParity-6431.test.tsx`'s `DIALOG_READS` gains `run`; that pin goes red when only one runtime carries the key. - `packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx` (a comment) and `MetadataTypeActions.paramDialogClose-6473.test.tsx` (a header line): both quoted the old submit expression, which this change replaces. The census pin in that suite (two settle-then-close sites in the dialog) stays green unchanged, because `handleSubmit` keeps one close site. - No open pull request touches any of these files (REST read of the open PRs' files at the time of writing). ## Evidence, at head `84d0fb1` - Targeted suite: every test under `packages/core/src/actions/`, plus every `ActionParamDialog*`, `RecordDetailView.param*`, `RecordDetailView.confirmRuntimeParity*`, `useConsoleActionRuntime*` and `MetadataTypeActions*` suite in app-shell, `page-header-actions` and the `renderers/action` suites in components, and `packages/react/src/`. Result: `Test Files 222 passed (222)`, `Tests 2947 passed (2947)`. - New pins: - `ActionRunner.paramRun-13161.test.ts`: 7 tests on the runner session; - `ActionParamDialog.refusalKeepsInput-13161.test.tsx`: 9 tests on the dialog; - `useConsoleActionRuntime.paramRefusal-13161.test.tsx`: one end-to-end test with a real `ActionRunner`, the console runtime's own handler and the real dialog. - Ablation 1, the dialog. Committed fix, then `ActionParamDialog.tsx` written back to the base blob. - Mutation on disk: `git hash-object` `89f103a` equals the base blob, with `state.run` and `action-param-refusal` counted at 0. - Run: `Tests 9 failed | 1 passed (10)` across the dialog suite and the end-to-end suite. The ablation target fails at `Unable to find an element by: [data-testid="action-param-refusal"]`. The one green test is the no-`run` control, by design. - Restore: `git checkout HEAD --` the absolute path, then `git hash-object` `fac7fb7` equals the HEAD blob, and `git diff HEAD` is empty. - Ablation 2, the runner. Same procedure on `ActionRunner.ts`. - Mutation on disk: `5778e79` equals the base blob, with `dispatchCollected` counted at 0. - Run: `Tests 8 failed | 6 passed (14)`. All 7 session pins and the end-to-end test fail. The 6 legacy `ActionRunner.params.test.ts` tests stay green both ways, which is the old-contract control. - Restore: `074b624` equals the HEAD blob, and `git diff HEAD` is empty. - `type-check` for `@object-ui/core` and `@object-ui/app-shell`: exit 0 (both programs, `tsc --noEmit` and `tsconfig.test.json`; app-shell's test program includes every `*.test.tsx`). - `lint` for both packages: exit 0, 0 errors. - Each gate printed its own verdict and exited 0: - `pnpm check:new-line-citations`: `VERDICT new-cross-file-line-citations: 0 new citation(s)`; - `pnpm check:control-bytes`: OK; - `pnpm check:changeset-claims`: no findings; - `node scripts/check-changeset-presence.mjs`: 10 source files of 2 released packages, 1 changeset; - `node scripts/check-changeset-no-major.mjs`: OK; - `node scripts/check-governed-queue-guard.mjs --test` over the 11 changed paths: NOT GOVERNED. - No user-facing string was added (the refusal is the run's own text and the busy state is a spinner), so the i18n gates were not run. ## Acceptance notes - **Field marking has no channel.** `ActionResult` carries a refusal as text, and every console handler (`apiHandler`, core's server-action handler, the flow handler, the runner's update path) flattens the envelope's `fields` to it. A refusal whose envelope does name a field is therefore also shown as the form's. Whether to widen `ActionResult` is a question in the report, not a change made here. Carrier: none. - **`MetadataTypeActions` still closes on Confirm.** It runs without the runner, and no metadata type's type-level action declares params today: the datasource type's `test_connection` and `migrate_credential` declare none. The path is dormant. Carrier: none. - **A run that never settles leaves the dialog busy with Cancel disabled.** The run cannot be called back, and closing would hide its outcome, which is the defect this card fixes. The cost is accepted and stated here rather than handled. - **The runner still toasts a refusal** while the dialog shows it, which is the object form's precedent (banner plus toast). A success toast is unchanged. - **New `Record of string to any` spellings** in the runner and its test add `no-explicit-any` warnings. They mirror the handler's own return type. The repo sets no `--max-warnings`. - **Out of scope, for its owner** (reported, not fixed): Users, then Create User left blank, answers "Either email or phoneNumber is required" on a form with no phone field. The `sys_user` `create_user` action declares `email` with `required: false`, because the endpoint takes email OR phone and the phone param is feature-gated. So the dialog's own "is required" check cannot fire, and the server's copy names a field the form does not show. Session: `https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7` --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…n it does not name (#13173) Fixes #13138 Clause-②: no Pin (c) is in this PR, under seat ruling `6285028835` (claim surface amended to `FlowObjectListField.tsx` and `FlowNodeConfigField.tsx`): - `unpairedDecisionEdges` in `flow-decision-edges.ts` returns the Decision's plain out-edges that no branch is paired with. - `FlowNodeConfigField` passes the edge branches filtered to them. - `FlowObjectListField` draws those read-only connection cards beside the branch rows, not only while the list is empty. - A paired connection is never listed twice. Pin: `FlowObjectListField.addBranchKeepsEdges-13138.test.tsx` (2 tests). Ablation: restoring the rows-empty gate turns it red, with the control green. ## What changed `packages/app-shell/src/views/metadata-admin/inspectors/flow-decision-edges.ts` — the Decision branch to out-edge reconciliation: - Every fallback pairing (the objectui#1927 by-order mirror, the derived Target column, and the "same target node" tier when a target is picked) now pairs a branch only with an out-edge that carries no routing of its own (no `condition`, `label` or `isDefault`), or with an edge the stored branch list owned before this commit. An edge no branch owns — a connection whose routing was written in the connection inspector — is driven only by a branch that binds it. - `applyDecisionBranches` and `syncDecisionEdgesByOrder` take a required `prior` argument: the node's stored `config.conditions` that the commit replaces. It tells "this branch's own edge, edited one cell at a time" apart from "a connection no branch has routed". Without it, a label-less branch whose expression is edited would lose its own edge and a duplicate with the stale guard would be created (measured on the first pass of this branch). - The detach path (a target-less branch removes the edge bound to it) now only removes an edge the branch list owned: a new, empty branch binds the default edge by the objectui#1930 matcher, and on base that deleted a connection-inspector default edge outright. `FlowNodeInspector.tsx` passes `getFieldValue(node, field)` as `prior` at both call sites. No export, key or prop moves. ## Measured on base first (ruling 4) The new pin file run against the `origin/main` blob of `flow-decision-edges.ts` (96cc52d), through `applyDecisionBranches` replaying the editor's one-cell commits: - card case (a) — one out-edge `{condition: {dialect: cel, source: "1 == 1"}}`, Add branch Label=Otherwise, Expression=true, no target ⇒ `[{id: edge_1, target: end, label: Otherwise, isDefault: true}]`: the condition gone. - card case (b) — Target=End picked first ⇒ the same single overwritten edge (the same-target tier claimed the conditioned edge on the first commit). - `Tests 5 failed | 6 passed (11)`: all five 13138 pins red, the six controls green. - A further base probe: an existing branch plus a connection-inspector `isDefault` edge; adding an empty branch and committing its label deleted that edge (`[e1]` out of `[e1, e2]`). ## Tests - New `flow-decision-edges.addBranch-13138.test.ts` (15 tests): pins (a), (b) incl. spec-parse of the produced edges, the new-branch-never-detaches pin, and controls (d): by-order pairing of unrouted edges, expression/default/rename edits of an existing branch, label-less expression edits (targeted and by-order), clearing an owned branch's target still detaching, claiming an unrouted drawn edge. - `flow-decision-edges.test.ts`: every call passes `prior`; the "updates the existing edge in place" fixture is back to its original label-less edge, now with the stored branch that owned it. - Family (every test file that imports or names `flow-decision-edges` / `FlowNodeInspector`, 51 files) at 307cb8fbbd: `Test Files 51 passed (51)`, `Tests 776 passed | 1 skipped (777)`. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`; the test config lists all 3 touched reconciliation files) after the closure build: exit 0. - The whole `packages/app-shell/` directory is declared to CI (shared test lock). ## Ablation (one-off, no permanent file) At 307cb8fbbd, `scripts/ablation-replace.mjs` replaced the pairing guard `(owned.has(i) || !carriesRouting(edges[i]))` with `true` (the unconditional positional mirror): anchor x1 to x0, blob 42e99b4a54 to aef90adcd9. The pin file went `Tests 4 failed | 11 passed (15)` — all three (a) pins and the (b) Target=End pin red. Restore: blob after restore 42e99b4a54 equals the HEAD blob, `git diff HEAD` empty, rerun `15 passed (15)`. ## Gates (at 307cb8fbbd; re-run at `e532118089` with pin (c): family of 63 files / 961 tests, type-check and the battery all exit 0) Exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `scripts/check-hand-rolled-comment-mask.mjs`, `scripts/check-changeset-fixed.mjs`. `check:spec-floors` exit 1, `no-artifact` for packages not built in this worktree (recorded, as the order expects on main). eslint narrowed to the 4 changed source files (`--no-inline-config --format json`): 4 files, 0 errors, 4 warnings, all on pre-existing lines of `FlowNodeInspector.tsx` (412, 436, 455). `eslint.config.js` configures no type-aware linting (no `parserOptions.project` / `projectService`), so this diff cannot move any untouched file's verdict. ## Acceptance notes - Resumed from the pushed sha 634ec94323 after a container restart; every verification above was re-run in this session. - The engine-published form path (no Target column) uses the same `prior` rule through `syncDecisionEdgesByOrder`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…trigger shows the picked record (objectui#13162) (#13180) Fixes #13162 Clause-②: no ## What was wrong, as measured Both halves were measured in Chromium (1440×900) against HotCRM `main` `f22dfa52`, booted locally with its npm `@objectstack/*` 18.0.0-next.5. That release's prebuilt console bundles objectui `4016370`, whose `LookupField.tsx`, `useRecordQuery.ts` and popover wrapper are byte-identical to `main`. Two hosts were driven: the Delegations create form (an object form in a dialog) and the Set Manager action dialog. The server answers the searched and the unsearched `GET /api/v1/data/sys_user` with the same row shape, `id` included, so the answer is not the cause. **(a) After typing `mar` (one match), on the Delegator lookup:** | how the pick was made | published console (before) | this branch | |---|---|---| | click on the row once the answer landed | set | set | | click at once, before the debounce fired | set | set | | Enter, no arrow key | **nothing set**, popover stays open | set | | ArrowDown before the answer landed, Enter after | **nothing set** | set | | ArrowDown then Enter, after the answer | set | set | | mouse click at coordinates read before the answer landed | **nothing set**, popover dismissed | nothing set (see Acceptance notes) | The Set Manager dialog gives the same readings, so the cause is the field, not `ActionParamDialog.tsx` (objectui#13161 holds that file; it is untouched here). **(b)** A full-size single-select lookup drew its value as a chip above a trigger that kept reading "Select…". This held after a pick with or without a search, and for a value the record already held. The chip did carry the name; the trigger never did. ## What changed - **Enter after a search (half a).** Once the rows on screen answer the typed term, the first row is the active row (named by `aria-activedescendant`), and Enter takes it. A keystroke clears the active row. While the answer is still pending (debounced or in flight), no row is made active, so Enter can never take a row from the previous term's list. With no term typed, nothing changes. - **The signal behind it.** `useRecordQuery` (internal to `@object-ui/fields`, not on the package entry) reports `answeredSearch`, the term its `records` answer. It is set in the same commit as `records`, and a superseded answer commits nothing (objectui#10712), so it cannot set it either. The field compares it with the typed term. A guess inside the field from loading transitions was rejected: it has a window, a keystroke processed between the debounce firing and React's render, in which an old term's rows read as answering the new one. - **The trigger shows the record (half b).** A single-select field draws the record's label in the trigger in every size, as compact mode already did, with the whole label on hover when it is cut short. A search-first (people) field also draws the avatar there, through the same read gate the chip used. The single-select chip goes, so the name is drawn once; its ✕ moves beside the trigger with the same accessible name ("Remove LABEL") and the same disabled rule. Multi-select keeps its chips and its "N selected" trigger. The placeholder reads muted in both sizes. The picked row already carries the label, so nothing is read again to draw it. **File surface, declared.** The claim named `LookupField.tsx`. The exact "rows answer the term" signal belongs to the query kernel beside it, `useRecordQuery.ts`, so this PR also touches that file: one additive member on an internal hook result. No open PR touches either file (all 9 open PRs read, 123 files). No export, prop or accept set moves: the built `dist/index.d.ts` has 0 hits for `UseRecordQueryResult` and `answeredSearch` (control: `LookupField`, 3 hits). The hook's own declaration file ships under `dist/widgets/` as every widget's does, and is reachable from no `exports` key. ## Evidence - **Pin:** `LookupField.pickSticks-13162.test.tsx`, 13 tests, driving the real `LookupField` through a controlled host. - **Ablation 1, both halves.** The fix's two source files were set back to their `d05f33b` bytes (both hashes checked against the base blobs), then the pin was run: 7 failed, 6 passed. The 7 are the behaviour tests: three for (a) and four for (b). For (b), "Priya Raman" was expected and "Select…" received. The 6 are the controls and the pending-Enter guard. Restored with `git checkout HEAD`, `git diff HEAD` empty, both hashes equal to HEAD's: 13/13. - **Ablation 2, the guard.** The "rows answer the term" condition was dropped from the default row (anchor hit once, blob changed, restored blob equal to HEAD). Only the pending-Enter guard went red: an active row appeared while the answer was still held. - **Live, after, on this branch's console** (dev server proxied to the same backend). Set Manager: type `mar`, and "Marta Quinn" is the active row. Enter puts it in the trigger, and Confirm sends `POST /api/v1/auth/admin/set-user-manager` with `managerId`, which answers 200 and closes the dialog. Business Units, New, parent: one click with no search, and the trigger reads "QA EMEA Sales". The Delegations table above, right-hand column. - **Local gates at `0498442`** (exit codes captured before any pipe): - `pnpm --filter @object-ui/fields lint`: 0, with 0 errors. The touched source files carry no new warnings: `LookupField.tsx` went from 53 to 52, `useRecordQuery.ts` stayed at 6. The pin has none. - `pnpm --filter @object-ui/fields type-check`: 0. - Vitest over 111 files that render or test the lookup pickers, across fields, app-shell, plugin-form, plugin-grid, plugin-detail, components and types: 1543 passed. - `check:new-line-citations` 0 (0 new citations), `check:control-bytes` 0, `check:changeset-claims` 0, `check-changeset-presence` 0, `check-changeset-no-major` 0. - i18n gates are not owed: no string or key changes, and the clear control reuses `lookup.remove`. - **Changeset:** `.changeset/13162-lookup-pick-sticks.md`, `@object-ui/fields` patch. ## Acceptance notes - **A click aimed at the rows shown before the answer lands can still reach no row.** Typing narrows the list only when the debounced answer lands. A click aimed at "Marta Quinn" in the three-row list, but pressed after the list shrank to one row, lands below the shrunken popover, which then dismisses. Nothing is set, and nothing could be: the press never reached a row. Measured on both consoles. A click that reaches a row always commits it, before and after this change. The open question in the dev report covers whether anything should change here. The candidate, narrowing the loaded rows on each keystroke, would add a second, client-side search beside the server-resolved one (ADR-0061), so it is not done here. - **The card's "text click" failure did not reproduce.** A Playwright locator click on the row committed, both once the answer had landed and at once after typing. A locator click measures the row, then presses its centre, so a list that shrinks between the two is the pointer race above. That is the likely reading, not a measured one. - **Observation, not filed:** a single-select value whose record no longer resolves (deleted, unreadable) draws the placeholder once hydration settles. This is unchanged by this PR, and was not measured in a browser. - **The live harness.** HotCRM at `f22dfa52`, booted `--fresh`. Two throwaway users and one business unit were created through the admin API. Everything ran on private ports and was stopped by PID after the readings. The Delegations create request answered 403 for a delegator other than self: that is the card's sibling finding F-14, not this one. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Implemented by a dispatched `os-dev` run; PM session `https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7`. --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…side Overrides (#13179) Fixes #13145 Clause-②: no ## What changed Settings pages (Setup, Configuration) can now take a value back to unset, and an override back to the inherited value. No server change: the settings door already reads `{"key": null}` as "clear this layer's value" (measured on the card). - `apps/console/src/pages/settings/SettingsField.tsx` - An emptied text-like box (text, email, url, phone, password, textarea, and the free-text box of a `valueDomain` select) now puts `null` in the draft, never `""` (helper `textOrUnset`). The number control already did this and is unchanged. The closed Radix select cannot be emptied, so it is unchanged. - A row whose source badge reads "Overrides" (`console.settingsView.sourceBadgeOverrides`) shows one small "Reset to default" button beside the badge. It calls the row's `onChange(null)`: the key goes into the draft as `null`, the page turns dirty, and the existing Save sends it. After the save the badge is whatever the server's read-back for that row says. The client sets no source state of its own. The badge choice moved from `InheritanceBadges` into `sourceBadgeOf` (pure, same rules) so the header can tell an Overrides row from the rest. The button hides once the draft already holds `null` for that key, and it is disabled while saving. - `packages/i18n/src/locales/*.ts`: `console.settingsView.resetToDefault` in all ten packs. - `.changeset/13145-settings-unset-and-reset.md`: patch for `@object-ui/console` and `@object-ui/i18n`. The API client was re-read: `saveSettingsNamespace` sends `JSON.stringify(patch)`, which keeps `null` values (only `undefined` is dropped). The new pins read the PUT body as it leaves `fetch`, so a dropped `null` would turn them red. ## Pins New `apps/console/src/pages/settings/__tests__/SettingsView.unsetAndReset-13145.test.tsx`. It drives the whole page over a stubbed `fetch` through the real `../api`: - (a) clearing a text field and saving sends exactly `{"smtp_host":null}`. A cleared password box sends `{"smtp_password":null}`. - (b) "Reset to default" on an Overrides row sends `{"default_country":null}`. The row then shows the server answer's "Inherited from" badge, with no Overrides badge and no reset button. - (c) CONTROLS: a typed value still sends the string. The number control sends `2525` as a number. An Inherited row and an unbadged row offer no reset, and the page has exactly one reset button. `SettingsField.sourceBadgeLocale-12694.test.tsx`: the badge matcher now skips text inside a `button`. The zh copy of the new action ("恢复默认") contains the default layer's word, so the matcher would otherwise count it as a second badge. The badge assertions are unchanged. ## Ablations (one-shot, through `ablation-replace.mjs`, wrapped and restored) 1. `return raw === '' ? null : raw;` replaced with `return raw;`. Result: the (a) cases go red: "a cleared text field is sent as null" and "a cleared password box is sent as null". `Tests 2 failed | 4 passed (6)`. Restore: blob f98737e == HEAD, and `git diff HEAD` is empty. 2. The reset render guarded with `false &&`. Result: (b) goes red, and so does the CONTROL's "exactly one reset" count. `Tests 2 failed | 4 passed (6)`. Restore: blob f98737e == HEAD, and `git diff HEAD` is empty. ## Verification (head 70c30d495f) - Closure build `pnpm --filter '@object-ui/console^...' build`: exit 0. - `pnpm --filter @object-ui/console --filter @object-ui/i18n type-check`: exit 0. The console's tsconfig includes `src`, so it covers the tests. - vitest `apps/console/src/pages/settings/`: 22 files, 180 tests passed. Ran at the final head. - vitest `packages/i18n/src/__tests__/` plus the app-shell product-neutral ratchet: passed, at ed7baec5cc. The later commit touched only the badge-pin test. - eslint on the 12 changed `.ts`/`.tsx` files, as the package `lint` scripts run it: 0 errors. Type-aware linting is not configured, so these files cannot move the verdict on files they do not touch. With `--no-inline-config`, one `react-hooks/static-components` error shows on an existing, already-disabled line that this PR does not touch. The base version shows the same error. - Gates, each exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `check:new-line-citations`, `scripts/check-hand-rolled-comment-mask.mjs`, `scripts/check-changeset-fixed.mjs`, `scripts/check-changeset-presence.mjs`. - Declared to CI, not run in full locally: the whole `apps/console` and `packages/i18n` test directories, and the repo-wide `pnpm lint`. ## Acceptance notes - After a reset, the badge follows the server's `cascadeChain`. If only the manifest default is left, the effective scope is `default`. Today's badge rules draw no badge for that case, not an "Inherited" one. This PR does not change those rules. - Password: the box never shows the stored secret. Typing into it and then emptying it now sends `null`, which clears the stored secret. Before this PR it sent `""`. Per the ruling, emptied means unset. - The `color` row's hex text box and the `json` textarea still send what is typed. They are outside the ruling's text-like list. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…d, not the first column (objectui#13150) (#13182) Fixes #13150 Clause-②: no ## What this changes On phone-width screens, `ObjectGrid` draws each record as a stacked card. The card branch took the first displayed column as the card title (`const titleCol = displayColumns[0]; // First column is always the title`). A view whose first column was Owner, a lookup, titled every card "Dev Admin", and the record's own name ("Plant A check") sat in a body row at the bottom of the card. The card is now titled by the object's declared `nameField`: - **Read through `declaredNameField`.** This is the resolver's single spelling of the pointer, and the same one the bulk confirm step names records by (objectui#13107). `ObjectGrid` already imports it and already holds the object definition (`objectSchema`, the same value it hands `BulkActionDialog` as `objectDef`). No second way to find the name field is added, and nothing guesses a field by its name. - **The name field is a shown column:** that column titles the card. The card body is every other shown column, so the name is not shown twice, and the first column it displaced (Owner) reads as an ordinary body row. - **The name field is not a shown column:** the title reads the row's value of it. Measured first: both fetches that feed this grid already ask for the declared `nameField` whether or not a column shows it (`ObjectGrid`'s `getSelectFields`, objectui#13107, and `ListView`'s `$select` harvest, objectui#13116). A masked name field with no column has no `cell`, so the title draws `MaskedCellRenderer` for it, which keeps the fail-closed rule. - **The row has no value for the declared name field** (for example, the viewer may not read it, so it is not fetched): the title shows the card's existing empty marker (an em dash). No other field stands in for the declared name. - **The object declares no `nameField`:** the first shown column stays the title, and the body is unchanged. The card's title element now carries `data-testid="grid-card-title"`. That is the selector for the browser reading at 390x844. Before this change the title was an untagged `div.font-semibold.text-sm.truncate.mb-1`, and its classes are unchanged. Nothing is added to the package entry: no export, prop, schema key or type member. The grid's empty state in the same file is untouched (objectui#13142 is not addressed here). ## Pins New file `packages/plugin-grid/src/__tests__/mobileCardTitleNameField-13150.test.tsx`. It drives the real `ObjectGrid` at `innerWidth` 390. The data source honours `$select` the way a server does: a row carries the selected keys only. - The measured shape: columns Owner (lookup), Notes, Name, with `nameField: 'name'`. Each card is titled by its name, its body shows "Dev Admin", and the name does not appear again in the body. - `nameField` not a column (columns Owner, Notes): the cards are still titled by the name, and the last `find` call's `$select` carries `name`. - The server leaves `name` off every row: both titles read as an em dash, never "Dev Admin". - CONTROL: an object that declares no `nameField` keeps Owner ("Dev Admin") as the title, with the name in the body. ## Ablations (one-off, through `scripts/ablation-replace.mjs` in the objectstack checkout; no permanent test) The test imports `../ObjectGrid` from source, so no build sits between the mutation and the run. Each mutation landed on disk (anchor count 1 to 0). Each was restored and checked: the blob equals the HEAD blob `790065a2608d` and `git diff HEAD` is empty. 1. Title back to the first column (`titleCol = displayColumns[0]`): blob `790065a2608d` became `21f43c2a79d8`. 3 of 4 went red, each receiving `[ 'Dev Admin', 'Dev Admin' ]`. That is the card's symptom, reproduced. The control stayed green. 2. Body no longer drops the title column (`bodyColumns = displayColumns`): blob `790065a2608d` became `cc455adef36f`. The measured-shape pin went red (`expected 'OwnerDev AdminNotesBoiler roomNamePla…' not to contain 'check'`); the other 3 stayed green. ## Gates (head `17838dd`) - `pnpm exec vitest run` on the new file: 4/4 passed. - Every `plugin-grid` test that sets `innerWidth` (the tests that reach the card branch), plus `bulkConfirmNameField-13107.test.tsx`: 18 files, 98 tests passed. - Every `app-shell` test that sets `innerWidth`: 26 files, 129 tests passed. - The full `packages/plugin-grid/` directory and `pnpm lint` are left to CI. - Dependency closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' build`: exit 0. - `pnpm --filter @object-ui/plugin-grid type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. The new test is in the test program (`--listFilesOnly`). - `node scripts/check-changeset-presence.mjs`: exit 0. It found 1 changeset for 1 released package. - `pnpm check:new-line-citations`: exit 0, 0 new citations. - `pnpm check:control-bytes`: exit 0. - `eslint` on the two changed source and test files: 0 errors. The warnings are `no-explicit-any` in the test file, in the same style as the sibling `mobileCardLookupDisplay` test. - No i18n keys and no README or `content/docs` fences were touched. ## Acceptance notes - **The displaced first column keeps its desktop cell face in the body.** `generateColumns` makes column one the record link (`primary-field-link`). When the name field takes the title, that column moves into the card body and is drawn through its own `cell`, so its value is styled as a link. The link opens the same record that tapping the card opens, and it stops the event from reaching the card's own click handler. A lookup column elsewhere in the body is already drawn as a link, to the referenced record. This is noted only. It needs a card-only face from the column builder, which is outside this claim's file surface. - **A viewer who may not read the name field** now sees an em dash as every card's title, where before the title was the first column's value. This is the "never another field" rule the bulk confirm step follows, and the body still shows the other columns. - No README or guide text describes how the card picks its title, so no docs changed. - `changeset`: `@object-ui/plugin-grid` patch. The session that wrote this: `https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG`. --- _Generated by [Claude Code](https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG)_ Co-authored-by: Claude <noreply@anthropic.com>
… engine left unarmed (objectui#13159) (#13185) Fixes #13159 Clause-②: yes Setup › Packaged automation drew the activation FLAG as if it were the EFFECTIVE state: every packaged schedule flow the boot reports "NOT bound — disabled by deployment policy" showed a checked switch and an "On" badge, and only the small Details chip said otherwise. The Activation cell now keeps the two facts apart. ## What changed - `packages/app-shell/src/views/setup/packagedFlows.ts` — the page's reading-rules module (split out of the page file for testability). `joinPackagedFlows` now carries the runtime row's `unboundCause` (only as a non-empty string, absent otherwise, the way `reason` is carried). New pure function `activationReading(row)` returns `off` (flag off), `not-running` (flag on and the engine sent an `unboundCause`), or `on`. - `packages/app-shell/src/views/setup/PackagedAutomationPage.tsx` — the badge beside the switch follows `activationReading`: "On" (default variant), "Not running here" (outline variant, never destructive: a deployment policy is not a defect), "Off" (secondary). The switch is untouched: it still shows and writes the flag, so an author can keep the flag set for a deployment that does arm the flow. After a successful toggle the flows half re-reads `GET /automation/_status` (a flows-only counter, so the actions section is not reloaded): the toggle's answer is `{ name, enabled }` only, and the flip can change what the engine armed (switching a schedule flow on under the policy leaves it unarmed; enabling a subflow re-arms its callers through `rejudgeSubflowCallers`). A refused toggle does not re-read. - New i18n key `packagedAutomation.notRunning` in all ten packs. English "Not running here" and Chinese "未在此运行" are the words Studio's Automations rail already uses for the same state (`engine.studio.auto.notRunning`), so one state reads one way across Setup and Studio. This widens `@object-ui/i18n`'s `TranslationKeys`, so the claim's `Clause-②: yes` stands. - `.changeset/13159-automation-effective-state.md` — `@object-ui/app-shell` patch, `@object-ui/i18n` patch. The Details chip, its sentence and its no-classification rule are unchanged; its doc comment is updated because the structured field it waited for now rides the wire. No cause is named anywhere on the page: `unboundCause` is read for PRESENCE only. ## Premise check, measured against objectstack pin `f4755a68a797` - **H1 (source of the truth): holds.** At the pin, `FlowRuntimeState` in `packages/spec/src/contracts/automation-service.ts` declares `unboundCause?: FlowUnboundCause` ("Present exactly on the rows that carry `reason`"), and the engine's `getFlowRuntimeStates` spreads `{ reason, unboundCause }` together from `describeUnboundReason`, which answers for an enabled, trigger-declaring flow that is not bound and returns undefined otherwise. The `_status` route passes those rows through as `{ flows, total }`. Ancestry of the objectstack#23842 implementation (PR objectstack#23854, merge commit `6422da58e83a`) under the pin: local `git merge-base --is-ancestor` answered exit 1, a false negative, because the local objectstack checkout is shallow (13 commits of history under the pin). The REST compare reads both ways: `6422da58...f4755a6` answers `ahead`, ahead_by 321, behind_by 0; the reverse answers `behind`, behind_by 321. So the field is served at the pin and the card is not pin-blocked. - **H2 (two facts apart): holds**, implemented as above. The switch stays live and checked for an unarmed row. - **H3 (controls): pinned.** A bound flow reads "On", a flow with no trigger (`bound: false`, no cause) reads "On", a switched-off flow reads "Off", and clicking the switch on an unarmed row still sends `{ enabled: false }`. - **H4 (acceptance with ablation): see below.** ## Tests New, in `PackagedAutomationPage.test.tsx` (describe "the Activation cell says whether the flow runs"): the acceptance case, the controls, every cause read the same way and none styled as an error, the flag still written from an unarmed row, the post-toggle re-read, and no re-read after a refused toggle. New in `packagedFlows.test.ts`: `unboundCause` carried as a non-empty string and absent otherwise, and `activationReading` for each cause, on, and off. Run at `d126afb`, through the shared verify lock: `pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/views/setup/ packages/i18n/` gave `Test Files 89 passed (89)`, `Tests 1395 passed | 13 skipped (1408)`. **Ablation, at `d126afb`.** The fix was committed first. The two source files (`PackagedAutomationPage.tsx`, `packagedFlows.ts`) were checked out from the base `4c36eab`; an on-disk count confirmed the mutation landed (`packagedAutomation.notRunning` in the page 1 → 0, `export function activationReading` 1 → 0). The page test file then gave `Tests 4 failed | 17 passed (21)`. Red: the acceptance case ("Unable to find an element with the text: Not running here"), the controls case and the every-cause case (`expected 'On' to be 'Not running here'`), and the post-toggle re-read (`expected 'On' to be 'Not running here'`). Green by design: "still writes the flag" and "no re-read after a refused toggle", which guard behaviour the fix must keep. The restore used `git checkout HEAD --` under an EXIT/INT/TERM trap. It was proven by blob hash equal to HEAD for both files and an empty `git diff HEAD`. ## Gates (local, at `d126afb`; exit codes captured before any pipe) - `pnpm --filter @object-ui/app-shell type-check` → 0; `pnpm --filter @object-ui/i18n type-check` → 0. The dependency closure was built first with `turbo run build --filter=@object-ui/app-shell^... --concurrency=2` (28/28 tasks). - `pnpm --filter @object-ui/app-shell lint` → 0 (0 errors, no warning on a changed file); `pnpm --filter @object-ui/i18n lint` → 0. - `pnpm check:new-line-citations` → 0 (0 new citations); `pnpm check:control-bytes` → 0; `pnpm check:changeset-claims` → 0; `node scripts/check-changeset-presence.mjs` → 0; `node scripts/check-changeset-no-major.mjs` → 0; `pnpm check:i18n-keys` → 0; `pnpm check:i18n-drift` → 0 (1 key added, 0 en values changed); `pnpm check:i18n-dead-keys` → 0 (the new key is not listed); `pnpm check:pending-changeset-literals` → 0; `pnpm check:spec-symbols` → 0; `pnpm check:test-path-roots` → 0. - NOT MEASURED locally: `pnpm check:eager-locale-catalogues`. It answered PREREQUISITE NOT MET (it weighs `apps/console/dist/eager-closure.json`, which needs a console build), so the result is left to CI. The packs grew by one short key each. - Repo-wide runs (full `pnpm test` shards, `Build & E2E`, docs) are CI's. ## Acceptance notes - **File surface.** The claim names the page file. The row model and the join live in `packagedFlows.ts`, the page's own reading-rules module that was split out of the page file "because these are not components". Both it and its test are edited here. No other module is touched: `PackagedActionsSection.tsx` is untouched because packaged actions have no armed-or-not state beside their flag. - **Two derivations of one fact.** Studio's `deriveFlowRunStatus` derives "not running" from `bound` and `triggerType`. This page reads the contract's classifier `unboundCause`, the field objectstack#23842 added for exactly this page. At the pin the two agree by construction: `unboundCause` is present iff the row is enabled, unbound and declares a trigger. Not a defect, and no card is filed. - **ADR-0126 §7.4 reading.** §7.4 scopes the page to "on/off for this scope". The switch remains exactly that. The badge's "Not running here" is the same class of exception as the platform `reason` disclosure that objectui#9217 already admitted, because it is sourced from the engine, never inferred client-side. - A toggle-on can show "On" for one round trip before the re-read lands "Not running here". The switch is disabled only while the toggle request itself is in flight. - The dispatch named `origin/main` `91b658a`. By worktree creation it had moved, and the branch is cut from `4c36eab`. - No browser pass was run: the defect and its controls are pinned at the renderer with the fake server, and the ablation shows the acceptance case going red. Implemented by an os-dev agent in session `https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7`. --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ Co-authored-by: Claude <noreply@anthropic.com>
…13184) Fixes #13133 Clause-②: no ## What The inbox header's breakdown line read "4 total · 1 notifications + 3 pending approvals". `notifications.badgeNotifications` was interpolated with the named placeholder `{{unread}}`, so i18next never plural-selected and every pack held one string for every count. - `InboxPopover` now passes `count` to `notifications.badgeNotifications`, as `notifications.approvalsPending` beside it already does. The strings switch from `{{unread}}` to `{{count}}`. - Each of the ten packs carries the family its language needs, in the shape the gates already accept for `approvalsPending`: a base key plus `_one` / `_other`, with `_many` in es, fr and pt, `_few` / `_many` in ru, and all six CLDR categories in ar. The wording follows each pack's existing string and its `approvalsPending` forms. en reads "1 notification" / "3 notifications". - ja, ko and zh carry the base key plus `_one` / `_other` with one repeated string. They do not carry a single form, because `all-locales-key-parity.test.ts` requires every pack to define every `en` key; `approvalsPending` does the same in those three packs. ### Bounded in-place fix: `notifications.badgeApprovals` The span right next to it had the same defect: `{{approvals}}`, one form, so one pending approval read "1 pending approvals". It is fixed the same way under the role file's bounded in-place-fix exemption, because all four conditions hold: 1. It is the same defect class. 2. The fix is mechanical and its shape is pinned by `approvalsPending`. 3. It lives in the same two claimed files. 4. It runs through the same i18n gate family. The render pin below covers it as well. ## Tests - New: `InboxPopover.badgePlural-13133.test.tsx`. It renders the popover against a real i18next instance and the shipped `en` pack. The sibling `InboxPopover.badgeBreakdown.test.tsx` stubs `t()` with an interpolator that cannot plural-select, so it cannot see this defect. - With 1: "1 notification" and "1 pending approval". - With 3: "3 notifications" and "3 pending approvals". - CONTROL: the Approvals tab body (`notifications.approvalsPending`) still reads "1 pending approval". - Updated: `inboxBadgeBreakdown-i18n-7233.test.ts`. - `badgeTotal` keeps its named `{{total}}`. - The two addends are now asserted as count families: base, `_one` and `_other` exist, and every slot carries `{{count}}` and not the retired placeholder. - Targeted run at `59793beaa2`, under the shared lock with `--maxWorkers=1`: 8 files, 143 passed. The 8 files are: - the new pin, `InboxPopover.badgeBreakdown`, `AppHeader.inboxVariant`; - `inboxBadgeBreakdown-i18n-7233`, `all-locales-key-parity`, `count-families-11445`, `plural-categories-11432`, `en-zh-key-parity`. - Ablation, via `ablation-replace.mjs`: the anchor `count: unreadTopics,` was deleted from the call (1 hit to 0, blob `00c41f45f732` to `a6dd207ab8cf`). - The singular leg went red: it expected `/^1 notification$/` and received `{{count}} notifications`. The plural leg went red too. - The CONTROL stayed green. - The restore was proven by blob hash: after restore `00c41f45f732` equals HEAD's `00c41f45f732`, and `git diff HEAD` is empty. ## Gates - **Type-check:** `@object-ui/i18n` and `@object-ui/app-shell` `type-check` exit 0 (both `tsc --noEmit` and `tsconfig.test.json`). The `@object-ui/app-shell^...` closure was built first. The app-shell run was repeated at `59793beaa2`. - **Pre-tsc battery at `59793beaa2`:** all exit 0. The battery is: - `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`; - `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`; - `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`; - `check:control-bytes`, `check:test-path-roots`, `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`, `check-changeset-presence.mjs`. - **`check:i18n-drift`:** "2 en value(s) changed (4 key(s) added …), 18 pack value(s) followed". - **`check:spec-floors`:** exit 1. This is a known state on main, so it is recorded here and not touched. - **Lint, narrowed:** - The population is the root `eslint.config.js` that each package's `lint` (`eslint .`) reads. - `eslint --no-inline-config --format json` over the 14 changed `.ts`/`.tsx` files at `59793beaa2` reports 14 files, 0 errors and 8 warnings. All 8 are pre-existing `no-explicit-any` warnings on unchanged lines of `InboxPopover.badgeBreakdown.test.tsx`. - The config enables no type-aware linting (no `parserOptions` / `projectService`), so this diff cannot change the verdict on any untouched file. - **Declared to CI:** whole-directory runs of `packages/app-shell/` and `packages/i18n/`. The dispatch order forbids whole-package-directory runs on the shared lock. ## Acceptance notes - A host that overrides these two keys in its own resources must now spell `{{count}}`. The changeset says so. - Changeset: `@object-ui/i18n` and `@object-ui/app-shell`, patch. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ackages sheet (#13181) Fixes #13157 Clause-②: no ## What changed `PackageDetailSheet` (`packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`) gated its actions on `manifest.scope` being `system` or `cloud` (`isKernel`). That is a second rule beside the Scope badge's. The badge reads the row's served `writable` (objectstack#14375, objectui#12723). A project-scoped or scope-less code package, or a marketplace install, passed the scope rule, so HotCRM's package was badged "Read-only · code" and still offered every write action. The sheet now reads the badge's verdict, `pkg.writable === true` (anything else is never writable, the way the badge draws it). The write actions are offered only on a writable package: | Action | Read-only package | Server's answer on a read-only package (objectstack `origin/main` `a43405d596`, `packages/runtime/src/domains/packages.ts`) | |---|---|---| | Edit | hidden | `PATCH /packages/ID` calls `requireWritablePackage` (objectstack#24705): 422 `WRITABLE_PACKAGE_REQUIRED` | | Publish | hidden | `requireWritablePackage`: 422 `WRITABLE_PACKAGE_REQUIRED` | | Revert | hidden | `requireWritablePackage`: 422 `WRITABLE_PACKAGE_REQUIRED` | | Disable | hidden | `requireWritablePackage`: 422 `WRITABLE_PACKAGE_REQUIRED` | | Delete app | hidden | `requireWritablePackage`: 422 `WRITABLE_PACKAGE_REQUIRED` | | Duplicate | hidden | `requireDuplicableSource`: 422 `DUPLICATE_SOURCE_NOT_A_BASE` (the source must be a writable base) | | Adopt loose items | hidden | not refused (see Acceptance notes); it is a write into this package | | Enable (shown only when the package is disabled) | offered | not refused: the enable arm has no writability gate | | View info, Export | offered | reads | **Duplicate (ruling 2, measured from the server source):** it does not create a new writable package from a read-only one. The server refuses a read-only source before it mints the target, so it is a refused action and is hidden like the others. **Hidden, not disabled with a tooltip:** the badge's wording ("Read-only · code", "System", "Read-only") is already on the sheet, one row above the actions, and a tooltip on a disabled button does not show in most browsers. Adding a new sentence would need new strings in `i18n.ts`, which is outside this card's file surface. So every refused action is hidden, the same way for all of them. The existing kernel sentence ("This is a platform kernel package. Authoring actions are disabled.") still shows on system and cloud packages, which now also get View info and Export. Those are reads. A writable package's sheet is unchanged: the same buttons, in the same order. ## Tests New `PackagesPage.readOnlyActions-13157.test.tsx`, through the real `PackageDetailSheet`: - (a) read-only packages (project-scoped code package, scope-less, system) offer none of Edit, Publish, Revert, Disable, Duplicate, Adopt loose items, Delete app. View info and Export stay. - (b) CONTROL: writable packages (scope-less, and project-scoped) offer all of them. - (c) Duplicate and its form are absent on a read-only package. - A row with no boolean verdict offers no write action. A disabled read-only package offers Enable. Run at `8804d718b0`: - New test file: `Tests 8 passed (8)`. - Every test file naming `PackagesPage` (21 files, the new one included): `Test Files 21 passed (21)`, `Tests 306 passed (306)`. - `pnpm --filter '@object-ui/app-shell^...' build` exit 0, then `pnpm --filter @object-ui/app-shell type-check` exit 0. Its `tsconfig.test.json` includes `src/**/*.test.tsx`. - `eslint --no-inline-config --format json` on the 2 changed source files: 2 files, 0 errors. The 15 warnings are all on lines this diff does not touch. The root `eslint.config.js` sets no `parserOptions.project` or `projectService`: no type-aware linting, so this diff cannot change a verdict on an untouched file. - Gates, each exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `scripts/check-hand-rolled-comment-mask.mjs`, `scripts/check-changeset-fixed.mjs`, `scripts/check-changeset-presence.mjs`. - Declared to CI: the whole `packages/app-shell/` test directory (not run locally, by the dispatch's lock rule). **Ablation** (with `scripts/ablation-replace.mjs`, on the committed fix): anchor `const writable = pkg.writable === true;` replaced by `const writable = true;`. The anchor count went 1 to 0, and the blob went `d374aafcc4bd` to `fad646251e9d`. The new file went red: `Tests 6 failed | 2 passed (8)`. The two that stayed green are the two writable controls. Restore: the blob after restore is `d374aafcc4bda1d5730e6aa9f1bd09739300ecc3`, which equals the HEAD blob, and `git diff HEAD` is empty. ## Acceptance notes - **The dispatch named the wrong source for the verdict.** It said the badge's verdict lives in `package-scope.ts`, but the badge reads `pkg.writable`. `package-scope.ts` (`buildPackageScopeOptions`) filters Studio's authoring destinations by `scope` alone. The sheet now gates on the verdict the badge actually reads. `package-scope.ts` is not touched. - **The pending-drafts block still uses `isKernel`.** Its Publish (N) and Discard changes (N) buttons are not in this card's action list. Overlay drafts can be bound to a code package, and the server does not gate either draft door on writability. They are left as they were. - **Not measured, not filed:** (1) the server's `POST /packages/ID/adopt-orphans` arm calls no writability gate, so from the source alone it could bind loose items into a read-only package. The sheet no longer offers it there. (2) `buildPackageScopeOptions` keeps any package whose `scope` is not `system` or `cloud`, read-only scope-less ones included. Neither was exercised through a public door. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ Co-authored-by: Claude <noreply@anthropic.com>
…e importing." (objectui#13124) (#13186) Fixes #13124 Clause-②: no That line is copied verbatim from the claim. The delivered diff adds no string-table key and no export: no `grid.import.*` key in any language pack, so `@object-ui/i18n`'s `TranslationKeys` is untouched. Per the claim, the seat corrects the line to `no` at ACCEPT. ## What changed `ImportWizard`'s dialog subtitle chose its text from `step` alone (`upload`, `mapping`, else `grid.import.previewDescription`), and a finished import keeps `step === 'preview'`. So the result screen read "Import Complete / 1 created / 1 skipped" under "Review data before importing.". - On the result screen the header renders no `DialogDescription`. - The result screen's own headline (`grid.import.importComplete`, or `grid.import.importCancelled`) is rendered as the dialog's `DialogDescription`. `RESULT_HEADLINE_CLASS` overrides the primitive's `text-sm text-muted-foreground`, so the headline looks as it did. The dialog's `aria-describedby` therefore names the finished state, and exactly one description is mounted in every state. - The upload, mapping, preview and history subtitles are unchanged. - No new key, export, prop or type member. One changeset: `@object-ui/plugin-grid` patch. ## How the shape was chosen (the dispatch's H1 to H4, measured) **H1, every arm of the subtitle.** Read from the ternary and from every `setResult` site; no path that sets `result` moves `step`. | state | step | result | subtitle before | after | |---|---|---|---|---| | upload | upload | none | uploadDescription | unchanged | | mapping | mapping | none | mappingDescription | unchanged | | preview | preview | none | previewDescription | unchanged | | importing: sync, or a background job queued or processing | preview | none | previewDescription | unchanged (see Acceptance notes) | | result, not cancelled: sync server import, legacy per-row, job succeeded or failed, 405 refusal, thrown failure | preview | set | previewDescription | header: none; description: the importComplete headline | | result, cancelled | preview | set, cancelled | previewDescription | header: none; description: the importCancelled headline | | history | upload | none | historyDescription | unchanged | **H2, reuse vs add vs none.** - Radix, measured: the only `@radix-ui/react-dialog` in `pnpm-lock.yaml` is 1.1.23. Its `DialogContent` sets `aria-describedby` only while a `Description` is mounted (`descriptionPresent`), and its dist has no `console.*` call, so no warning fires either way. The repo already treats a dialog without a description as an accessibility gap (the fullscreen editor's sr-only description, pinned by objectui#3404's test). So "none" alone would trade the stale sentence for an undescribed dialog; ablation leg B below shows exactly that. - Reusing `importComplete` visibly in the header would print "Import Complete" twice, one line above the other. - The chosen shape reuses the existing done-state keys as the description, prints nothing twice, and adds no key. **H3, controls.** The upload, mapping and preview subtitles are pinned unchanged, each as the dialog's accessible description. **H4, ablation.** Below. ## Tests New: `packages/plugin-grid/src/ImportWizard.doneSubtitle-13124.test.tsx`, through the real wizard and a stubbed data source: - controls: the upload, mapping and preview subtitles, each what `aria-describedby` names; - a completed import (the card's repro, "1 created" and "1 skipped"): "Review data before importing." appears nowhere, and the description is the "Import Complete" headline, carried by exactly one element; - a cancelled background import: the same, with "Import cancelled". Expected copy is read by key from `IMPORT_DEFAULT_TRANSLATIONS`, so a rewording moves the test with it. Ablation: the fix was committed first; each restore is proven by the file's blob hash equal to HEAD's and an empty `git diff HEAD`. The test imports `./ImportWizard` relatively, so it reads `src/`; no `dist/` is involved. - Leg A (H4): the whole `ImportWizard.tsx` reverted to the base commit `4c36eab`. 2 failed, 1 passed: the completed and cancelled cases find the header description "Review data before importing."; the controls pass. - Leg B, the "none" shape (header subtitle dropped, the completed headline a plain paragraph again), through `ablation-replace.mjs` (anchor 1 to 0). 1 failed, 2 passed: the completed case finds no `aria-describedby` at all (`{ text: null, carriers: 0 }`). Local runs, all at `041498e` (this PR's head): - `pnpm exec vitest run` over the 40 test files that name `ImportWizard` or its result copy (derived with `git grep -l`): exit 0, Test Files 40 passed (40), Tests 356 passed (356). - `pnpm --filter @object-ui/plugin-grid lint`: exit 0, 0 errors; none of the file's existing warnings sits on a changed line. - `pnpm --filter @object-ui/plugin-grid type-check`: exit 0, after building the dependency closure; `tsc -p tsconfig.test.json --listFiles` lists the new test. - Exit 0 each, verdict lines green: `pnpm check:new-line-citations` (0 new), `pnpm check:control-bytes`, `pnpm check:changeset-claims`, `node scripts/check-changeset-presence.mjs`, `node scripts/check-changeset-no-major.mjs`, `pnpm check:pending-changeset-literals`, `pnpm check:test-path-roots`, `pnpm check:i18n-keys`, `pnpm check:shell-escape-residue`, `pnpm check:vi-mock-specifiers`. - `node scripts/check-governed-queue-guard.mjs --test` over the three paths: NOT GOVERNED. - Not run locally: `pnpm check:i18n-drift` (no i18n key or value changes); the repo-wide `pnpm test` and `pnpm lint` are CI's. ## Acceptance notes Observed, not changed here, not filed: - While an import runs (sync, or a background job queued or processing), the subtitle still reads "Review data before importing." above the progress bar. Same subtitle-follows-step class, but the dispatch keeps the other steps' subtitles unchanged, and no importing-state sentence exists to reuse without a copy decision. Carrier: none. - The step indicator still highlights "3. Preview" on the result screen. Carrier: none. - An import that fails outright (405 refusal, thrown server failure, background job `failed`) shows the green check and the "Import Complete" heading. With this change that heading is also the accessible description, exactly as visible. Read from source, not measured through the console. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ Co-authored-by: Claude <noreply@anthropic.com>
…r of the create button (objectui#13134) (#13187) Fixes #13134 Clause-②: no ## What was wrong At 390x844 the object-list page's floating create button (`mobile-fab-create`: `fixed`, `bottom-36`, `h-12`) sits over the end of the list, and nothing can scroll the last card clear of it. Measured before writing code (the PM's mechanism assumption 2, which box owns the scroll): **no box scrolls the phone card list at all.** Below 768px `ObjectGrid` draws stacked cards instead of the table, straight into `ListView`'s view area, which is `overflow-hidden`. So is every box between it and this page (the `ListView` root, `plugin-view`'s `ObjectView` root and content). The table scrolls in its own body; the card list had no body of its own. So: - a short list (the card's Projects, 5 records) fits, does not scroll, and its last card stays under the button; - padding the list, the card's "Expected", cannot fix it alone: padding inside a clipping box grows the clipped box and makes no scroll room (measured below); - a list taller than the screen is clipped, with nothing to scroll. The clearance needs a scroller first. Assumption 1 holds for the button. Assumption 2 resolves to the card list's producer, `@object-ui/plugin-grid`'s card view (not `plugin-list`, not the console shell). Assumption 3 holds: the button renders on `showCreate`. ## The fix - **`@object-ui/plugin-grid`, `ObjectGrid` card view:** the cards and the grid description scroll in a box of their own, `h-full overflow-y-auto overflow-x-hidden`, `data-testid="object-grid-card-scroller"`, marked `data-fab-clearance`. With no host padding it, it adds no room. Its host still bounds it, so `h-full` resolves. Under a host with no bounded height it grows with its content, as before. - **`@object-ui/app-shell`, `ObjectView`:** while the phone FAB is drawn, the list host carries `max-sm:[&_[data-fab-clearance]]:pb-48`, which is 12rem at the end of the marked scroller: the button's 9rem offset plus its 3rem height, so the last card scrolls above the button's top edge. `max-sm` is the button's own `sm:hidden`. The FAB and the room both read the existing `showCreate`, so they cannot disagree, and there is no room where the button is not drawn. Nothing moves in any package entry: no export, prop or schema key. `data-fab-clearance` is a DOM attribute, the same posture as `data-fab-yield` (`ConsoleChatbotFab`). ## Geometry reading (replica in Chromium, not the live console) The page is a replica built from the class strings of every box in the chain, read from this tree's source. That covers the layout `AppShell` with shadcn `SidebarProvider` and `SidebarInset`, the console `main`, `console-route`, `RouteFader`, this page's root, FAB and content chain, `plugin-view`'s `ObjectView` root and content, the `ListView` root, toolbar, view area and record-count bar, and `ObjectGrid`'s card list and card class. Tailwind was compiled from the installed `tailwindcss` 4.3.3 for exactly those classes, and card contents are fixtures. Browser: Chromium at `/opt/pw-browsers/chromium`, viewport 390x844. Input: 20 `mouse.wheel` steps (deltaY 400) over the list. Positions are px from the top. | tree | cards | box that scrolls | last card before | last card after wheel | FAB top | |---|---|---|---|---|---| | base | 5 | none | 573 to 683 (under FAB) | 573 to 683 (under) | 652 | | base | 20 | none (`ListView` view area clips 2368px into 718px) | 2343 to 2453 | unreachable | 652 | | base, padding on the card list only | 5 | none | 573 to 683 | 573 to 683 (under) | 652 | | this PR, FAB drawn | 5 | `object-grid-card-scroller` (padding-bottom 192px) | 573 to 683 | 501 to 611 (clear) | 652 | | this PR, FAB drawn | 20 | `object-grid-card-scroller` | 2343 to 2453 | 501 to 611 (clear) | 652 | | this PR, FAB not drawn | 5 | none (fits, padding-bottom 0) | 573 to 683 | 573 to 683 | none | | this PR, FAB not drawn | 20 | `object-grid-card-scroller` (padding-bottom 0) | 2343 to 2453 | 693 to 803 (flush end) | none | For the seat's live reading at 390x844 with real touch or wheel input: the scroll container is `[data-testid="object-grid-card-scroller"]`, the last card is `[data-testid="object-grid-card-scroller"] > .space-y-2 > :last-child`, and the button is `[data-testid="mobile-fab-create"]`. ## Tests - `packages/plugin-grid/src/__tests__/ObjectGrid.cardScroller-13134.test.tsx`: at 390px every card sits inside exactly one `data-fab-clearance` box, which is the scroller (`h-full`, `overflow-y-auto`) and carries no padding of its own. At 1280px (the table) nothing carries the marker. - `packages/app-shell/src/views/ObjectView.mobileFabListClearance-13134.test.tsx`: with create offered (plain, and greyed by `disabledWhen`), an ancestor of the list's marked scroller pads it by at least the `bottom-N` plus `h-N` read off the FAB's own classes, under `max-sm`, matching the FAB's `sm:hidden`. With create hidden by `visibleWhen`, or closed by the principal, there is no button and no room. Reverse verification. The fix was committed first (58c8790). Each mutation went through objectstack's `ablation-replace.mjs`: the anchor went from 1 hit to 0, the blob changed, and after the run the restored blob equals HEAD and `git diff HEAD` is empty. - app-shell leg, `listFabClearance` forced to `undefined`: predicted the two room cases RED and the two no-room cases GREEN. Observed `Tests 2 failed | 2 passed (4)`, both failures `expected null not to be null`. - plugin-grid leg, the scroller's classes and marker removed: predicted the 390px case RED and the 1280px case GREEN. Observed `Tests 1 failed | 1 passed (2)`, failing with `expected to have a length of 1 but got +0`. ## Gates (local, at HEAD 58c8790) - `pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/ packages/app-shell/src/views/ObjectView` (through `os-verify-lock.sh`): `Test Files 305 passed (305)`, `Tests 2732 passed (2732)`, VERDICT command-exit 0. - `pnpm turbo run build --filter='@object-ui/app-shell^...' --concurrency=2` (`Tasks: 28 successful, 28 total`), then `pnpm --filter @object-ui/plugin-grid type-check` and `pnpm --filter @object-ui/app-shell type-check`: both scripts echoed `tsc --noEmit && tsc -p tsconfig.test.json`, VERDICT command-exit 0. Each new test file is in its package's `tsconfig.test.json` program (`--listFilesOnly`, 1 hit each). - `node scripts/check-changeset-presence.mjs`: exit 0, "4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)". - `pnpm check:new-line-citations`: exit 0, "0 new citation(s)". - `pnpm check:control-bytes`: exit 0, OK. - Not run locally (CI's): repo-wide lint and the full test farm. No i18n key or doc fence was touched. ## Acceptance notes - Overlap: objectui#13150's in-flight branch also edits `ObjectGrid`'s card view (the `secondaryCols` line, a few lines below this PR's wrapper). The hunks are not adjacent. Whoever lands second merges `main`. - Not measured and not changed here: the console's AI launcher (`ConsoleChatbotFab`, `fixed bottom-20 h-14` below `sm`) also floats over the list's bottom-right. While the create button is drawn this room clears it too, since it sits lower. Where create is not offered and the launcher is drawn, the list gets no room for it. Not reproduced in a browser. Carrier: none. - Not measured and not changed here: other phone surfaces under the create button carry no `data-fab-clearance` box: the grouped grid (which keeps the table at phone width), kanban, gallery and calendar. Carrier: none. - The FAB's comment says it sits above "the bottom mobile-nav (h-12 + safe-area)". The console draws no bottom nav (the button sits above the AI launcher). Left as is, outside this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG)_ Co-authored-by: Claude <noreply@anthropic.com>
…ects (objectui#13165) (#13189) Fixes #13165 Clause-②: no ## What changed The Audit Log's Object filter (`apps/console/src/pages/system/AuditLogPage.tsx`) is now a searchable picker of the objects the console's metadata serves. It replaces the free-text box that sat under the placeholder "e.g. account". - Each option reads `Label (name)`, the spelling the object-ref picker gives an object (`objectRefOptionLabel` from `@object-ui/fields`). Options are sorted by that label in the display locale. - Choosing one commits the object's API name, as the Actor filter's `sys_user` lookup commits a user id (objectui#11701). - "Any object" is the unfiltered state. Choosing the chosen object again, or "Clear filters", removes the filter. - The visible "Object" label now names the control through `htmlFor` / `id`, because a `combobox` takes no accessible name from its content. No literal `aria-label` is added. - The query is unchanged. The list fetch still sends `object_name` as a plain-value equality in `$filter` to `GET /api/v1/data/sys_audit_log`. - No object is named in the page. The options come from `useMetadata().objects`, the served registry the presets already read, so the picker costs no new request. ## The hypotheses, measured on `origin/main` `186b78d` - **H1, the producer: confirmed, with one correction.** `load()` put the box's trimmed text into `filter.object_name`, serialized it into `$filter` and sent it to the data door `/api/v1/data/sys_audit_log`. That is an exact equality. The correction: the placeholder was not drawn from any app. It was the hard-coded literal "e.g. account" in the page. - **H2, pick rather than type: confirmed.** The page already reads `useMetadata().objects` for its presets. That is the whole served registry, the `object` type list behind `MetadataProvider`'s `objects` getter. The ledger's writers in objectstack name either the audited object itself (`ctx.object` in `audit-writers.ts`) or one of four platform objects: - `sys_session`, for login and logout; - `sys_setting` and `sys_platform_setting`, for config changes; - `sys_user`, for user administration and import. All four are declared in `packages/platform-objects`, so they are registered objects. The contains-match alternative was rejected on a measurement as well as on taste. objectstack's ledger read gate (`namedParentObjects` in `parent-record-read-gate.ts`) reads only a plain value, `$eq` or `$in` on `object_name` as a confinement. A contains operator would lose that narrowing, and this card is not about the door's filter semantics. - **H3, controls: pinned.** - Clearing, by re-picking or by "Clear filters", sends no `$filter` and lists every row. - Action and Actor each join the object filter in one `$filter`. - Paging keeps the object filter, and picking a new object starts again at page 1. - **H4, acceptance: pinned, and it goes red with the fix reverted.** Choosing Accounts sends `object_name` equal to `crm_account` and lists only its rows. Typing `account`, the card's own example, into the picker's search finds Accounts. ## Tests - New: `AuditLogPage.objectFilter-13165.test.tsx`, 8 tests. - Updated: `AuditLogPage.presets-12667.test.tsx`. Its preset-joins-the-bar pin drove the removed text box. It now picks `account` through the picker and asserts the same filter as before. Run at head `a47caf8`, through the shared verify lock: `pnpm exec vitest run --maxWorkers=2` over the four `AuditLogPage.*` suites, `consoleFaces.displayLocale-9909.test.tsx` and `orphanedPageComponentRefs-10520.test.tsx`. Result: `Test Files 6 passed (6)`, `Tests 51 passed (51)`, `VERDICT command-exit 0`. ## Ablation (one-off, no permanent file) The fix was committed first (`2583e95`). Then only `AuditLogPage.tsx` was checked out from base `186b78d` (blob `a240710`), with a trap that restores from `HEAD`. - **The mutation landed on disk.** `objectFilterOptionsOf` went from 3 occurrences to 0. `placeholder="e.g. account"` went from 0 to 1 (counted in the two blobs). - **The new file went red: `Tests 8 failed (8)`.** One test failed because the free-text input was present. The others failed on `Unable to find an accessible element with the role "combobox" and name "Object"`. That is the predicted direction. - **The restore is proven.** `git checkout HEAD -- path`; the disk blob `4aa9e0d` equals the HEAD blob, and `git diff HEAD` is 0 bytes. ## Gates (local subset, at `a47caf8`; exit codes captured before any pipe) - `pnpm --filter @object-ui/console lint`: exit 0, `0 errors, 220 warnings`. The two warnings on `AuditLogPage.tsx`, `catch (err: any)` and the setState-in-effect, were already on main. - `pnpm --filter @object-ui/console type-check`: exit 0, after building the closure with `pnpm --workspace-concurrency=2 --filter "@object-ui/console^..." build` (exit 0). `tsc --listFiles` lists all four `AuditLogPage.*` test files, so the check covers them. - Each of these exited 0: - `pnpm check:new-line-citations` (0 new) - `pnpm check:control-bytes` - `node scripts/check-changeset-presence.mjs` (1 changeset declared) - `node scripts/check-changeset-no-major.mjs` - `pnpm check:changeset-claims` - `pnpm check:pending-changeset-literals` - `node scripts/check-vi-mock-specifiers.mjs` - `node scripts/check-vi-mock-inherit.mjs` - `node scripts/check-vi-mock-override-shape.mjs` - `node scripts/check-test-path-roots.mjs` - `node scripts/check-governed-queue-guard.mjs --test` over the four paths answers NOT GOVERNED. - Not run locally, because they belong to CI: the full test shards and the repo-wide lint. No i18n gate applies, since no string moved into a translation table. ## Changeset `.changeset/13165-audit-log-object-picker.md` is a `patch` for `@object-ui/console`. The changeset-presence gate asks for it because `apps/console` is in the release's fixed group, although no export, prop or accept set moves. ## Acceptance notes (not changed here) - **The shared `Combobox` searches option values only.** `CommandItem` gets `value={option.value}` and no `keywords`, so typing an object's visible label finds nothing unless its letters also occur, in order, in the API name. A happy-dom probe, not committed, typed into a `Combobox` holding `Accounts (crm_account)` and `Opportunities (crm_opportunity)`: - `Accounts` and `Opportunities` showed no option; - `account` and `crm_opp` showed the matching one. The limitation lives in `packages/components/src/custom/combobox.tsx` and reaches every consumer whose labels differ from its values, the object-ref field included. Fixing it moves a published package, so it is outside this card's surface. No real-browser reading was taken, and there is no carrier. - **Other controls still lack a name tied to their label.** The Action select, the From/To date inputs and the Actor lookup trigger keep their visible labels unassociated. The test DOM dump shows the Action select as a `combobox` with an empty name. Pre-existing and left as it was. - **The table and drawer still print the raw API name.** The Object column and the drawer title show `object_name`, not the label. Polish, left as it was. - **Rows of an unserved object cannot be reached by this filter.** An object the metadata no longer serves (a deleted one, say) is not offered, so the filter cannot narrow the ledger to its old rows. The changeset says so too. Session: `https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7` --- _Generated by [Claude Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… wording (#13190) Fixes #13126 Clause-②: no ## What Studio's New package dialog described the package as "project-scoped" (zh 创建项目范围的软件包), a scope word ObjectStack retired when it renamed project to environment (ObjectStack ADR-0006). The dialog is not environment-scoped either (create sends no `scope`; the server defaults it, objectui#12869), so per the dispatch ruling the sentence now carries no scope word: - en: "Create a writable package to author and publish your own metadata. The id should be reverse-domain (e.g. {example})." - zh: "创建可写的软件包,用来编写和发布你自己的元数据。ID 建议使用反向域名格式(例如 {example})。" Only the `engine.packages.create.description` row changes (the table carries en and zh only). No existing pin asserted the old sentence, so no pin was updated. ## Files - `packages/app-shell/src/views/metadata-admin/i18n.ts` (the one row, en + zh) - `packages/app-shell/src/views/metadata-admin/PackageFormDialog.createCopy-13126.test.tsx` (new pin) - `.changeset/13126-package-dialog-copy.md` (`@object-ui/app-shell` patch) ## Pin and ablation The new test renders the dialog in create mode in en-US and zh-CN, reads the description through the dialog's `aria-describedby` link (positive control: the interpolated `com.acme.crm` is present), and asserts it does not match `/project/i` (zh: also not `项目`). Ablation via `objectstack/scripts/ablation-replace.mjs` (anchor hit 1 to 0, landed on disk), one leg per locale, at commit 0d9f30f2e1: - en leg: old en sentence put back: `× carries no "project" scope word in English`, `Tests 1 failed | 1 passed (2)`, exit 1. - zh leg: old zh sentence put back: `× carries no 项目 scope word in Chinese`, `Tests 1 failed | 1 passed (2)`, exit 1. - Restore proven by blob hash after each leg: `i18n.ts` = `c151bc60479f1e3ad5f4256aa2cb763e220de545` = the HEAD blob; `git diff HEAD` empty. ## Local verification (at 0d9f30f2e1) - Closure build `pnpm --filter '@object-ui/app-shell^...' build`: exit 0. - `pnpm --filter @object-ui/app-shell type-check`: exit 0 (`tsconfig.test.json` lists the new test file, counted with `--listFiles`). - New test: 2/2 passed. Touched-file family (all `PackageFormDialog.*`, `PackagesPage*` and metadata-admin `*i18n*` tests, 26 files): 223/223 passed. - eslint narrowed to the 2 changed source files (root `eslint.config.js`, no type-aware linting, so the diff cannot move any untouched file's verdict): 2 files, 0 errors, 0 warnings. - Pre-tsc battery, all exit 0: `check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`, `control-bytes`, `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`, `check-changeset-presence.mjs`. - Declared to CI: the full `packages/app-shell/` vitest directory (not run locally, per the dispatch's shared-lock rule). ## Acceptance notes Other rows in the same table that contain "project", left as they are: - `engine.packages.scope.project` (en "Read-only · code", zh 只读 · 代码包): only the key names the `project` wire value of `ManifestSchema.scope`; the shown text has no scope word. - zh `engine.packages.form.help.scope` (部署范围:cloud(云端)、system(系统)、project(项目)): names the literal `scope` enum value the spec still carries; it is drawn only in edit/view mode (create has no scope row), so it is not this sentence. Whether the gloss 项目 should change follows whatever the spec does with the enum value. - zh `engine.packages.form.help.namespace`: `todo_project` is an example object name, not the retired term. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ Co-authored-by: Claude <noreply@anthropic.com>
…abel where no card covers it (#13191) Fixes #13144 Clause-②: no ## What was wrong On the flow canvas every forward edge was the same vertical curve from the source card's bottom to the target card's top (`edgePath`), with its branch pill at the curve's midpoint (`edgeMidpoint`). Only back edges took a detour (`backEdgePath`). So a Decision branch that skips a rank (the card's Start → Decision → Notify → End, plus a Decision → End branch) ran straight under the Notify card. The line was hidden under the card, the pill sat under it, and a click there selected Notify. ## What changed - **`flow-canvas-layout.ts`: new `forwardEdgeRoute(from, to, obstacles, labelSize?)`.** It samples the old curve against every node box except the edge's own two. - **No box crossed:** the edge comes back as before, byte for byte: `edgePath` for the path and `edgeMidpoint` for the pill. - **A box crossed:** the edge turns aside onto a vertical lane, runs down it and turns back into the target's top. The lane sits `DETOUR_LANE_GAP` (24px, never more than `PADDING`) outside the boxes the edge would cross, and moves further out past any neighbouring box it would run through. - **Choosing a side:** a left lane and a right lane are both built and checked against every box. The one that crosses fewer boxes wins, then the one with the shorter way across. A tie goes left, away from the right-hand lane that revise loops use. The pill sits on the lane, below the first turn, in the open band above the first box. - **The same "label under a node" symptom on an edge between two cards.** An edge can skip a rank by running in the 44px gap between two cards. It crosses neither card, so its path stays the same. But its pill sat at the midpoint, in that rank, under both cards. When the caller passes the pill's size, the router moves the pill along the same curve to the open band below the source, next to the sibling branch pills. With no size (the region view draws no pills), nothing moves. - **`FlowCanvas.tsx`** draws everything on the route: the stroke, the wide transparent hit path, the pill, the insert handle and the problem badge. It passes each branch pill's estimated size. The Problems-panel reveal pans to the routed pill point. - **`flow-region-view.tsx`** routes nested-region edges through the same function. It passes no pill size. - **No public surface moves:** no export from the package entry, prop, schema key or accept set changes. `flow-canvas-layout` is not reachable from the package entry. ## Browser reading (console preview gallery, no backend) Recipe: objectui's `verify` skill. Run `cd apps/console && pnpm exec vite --port 5291 --strictPort`, open `/preview-gallery.html?only=flow`, and drive it with the repo's Playwright (`executablePath: '/opt/pw-browsers/chromium'`). The gallery's flow sample has no Decision → End branch that skips a node. So the readings used three local-only edits to `apps/console/src/preview-samples.ts`. None of them is in this PR: - **A, the card's flow:** the sample's nodes and edges are replaced with start → decide → notify → end. decide → notify is `isDefault`; decide → end has `condition: "record.status != 'done'"`. - **B, the stock sample plus one edge:** `{ id: 'e_check_end', source: 'check', target: 'end', condition: "record.status != 'done'" }`. - **C, between siblings:** start → decide; decide → a and decide → b (with conditions); a → end and b → end; plus decide → end with the same condition. Each reading does four things: 1. Pans the canvas so the pill is in view. 2. Checks the drawn path at 201 points against every card's rect, and compares the pill's rect with every card. 3. Asks the browser which element is topmost at the pill's centre. 4. Clicks that point with a real mouse, then reads which edge the canvas marks as selected. The "after" rows were read on this branch. A was read again at the final head. | reading | path points inside a card | pill rect against the cards | topmost at pill centre | real click on the pill selects | |---|---|---|---|---| | A, main | notify 74 | overlaps notify | node notify | node notify, no edge | | A, branch | none | overlaps none | the pill | edge decide → end | | B, main | wait 16, task 16, review 16, notify 16, enrich 16 | in the task / review gap, under task → review's insert handle | the svg layer, not the pill | nothing; task → review's node palette opened | | B, branch | none | overlaps none | the pill | edge check → end | | C, main | none (the line runs in the gap) | overlaps a and b | the pill (its middle shows in the gap) | edge decide → end | | C, branch | none (path unchanged) | overlaps none | the pill | edge decide → end | Path against the skipped node in A, in canvas coordinates. Notify's box is x 28 to 268, y 272 to 338. - **main:** `M 148,216 C 148,305 148,305 148,394`. This is a vertical line at x 148, through Notify's box, and the pill point (148, 305) is inside it. - **branch:** `M 148,216 C 148,230 4,230 4,244 L 4,338 C 4,366 148,366 148,394`. The lane at x 4 stays 24px left of Notify. The pill point (4, 258) is in the band between the Decision's bottom (216) and Notify's top (272). The stock sample's own `e_skip_end` (Mark as not due → End, which has no pill) was already drawn through four cards on main. 401 points on the old curve fall inside task 32, review 53, notify 44 and enrich 32. The branch draws `M 432,826 C 432,840 434,840 434,854 L 434,1436 C 434,1464 290,1464 290,1492`: it runs down the right-hand side, and no point falls inside a card. ## Tests The new tests are `forwardEdgeRoute — an edge that skips a node is drawn around it (objectui#13144)` (10 cases, in the existing `flow-canvas-layout.test.ts`) and `FlowCanvas.skipEdgeRoute-13144.test.tsx` (6 cases, DOM). The DOM cases read each card's box from its `left`/`top`/`width`/`height` styles and the pill's box from its `foreignObject`. They pin four things: - the path and the hit path stay clear of every card; - the pill box overlaps no card; - the between-siblings pill leaves the rank; - region-view edges stay clear of region nodes. One DOM case is labelled as a control. Happy-dom does no hit-testing, so the pill's click wiring passes on main too. The real click is in the browser table above. Runs (repo root, through the shared verify lock): - **New tests:** `pnpm exec vitest run --maxWorkers=2 --reporter=verbose packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.test.ts packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.skipEdgeRoute-13144.test.tsx` gave `Test Files 2 passed (2)`, `Tests 49 passed (49)` at `84e17ad`. `b596a0d` changes only the changeset. - **Flow suites:** `pnpm exec vitest run --maxWorkers=2` over the `previews/FlowCanvas`, `previews/FlowPreview`, `previews/flow-`, `previews/flowFieldChrome`, `previews/__tests__` and `inspectors/FlowNodeInspector` path filters gave `Test Files 72 passed (72)`, `Tests 842 passed (842)` on the `84e17ad` tree. - **Type-check:** `pnpm exec turbo run build --filter='@object-ui/app-shell^...' --concurrency=2` exited 0. Then `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) exited 0. The second project compiles the package's tests. Ablations, each through `ablation-replace.mjs` with an anchor that must hit, and restored to a blob equal to HEAD: 1. **The router neutralised**, at `298ce70`: `if (obstacles.length === 0) return straight;` was replaced by `return straight;`. Result: 7 failed, 39 passed. The failures were the four routing cases in `flow-canvas-layout.test.ts`, the two canvas path/pill cases and the region-view case. The cases that stayed green are the unchanged-path controls and the click-wiring control. 2. **The pill move neutralised**, at `84e17ad`: the midpoint early return in `straightLabel` became unconditional. Result: 2 failed, 47 passed. The failures were `moves the pill of an edge running between two cards out of their rank, onto the same path` and `draws the straight line and puts the pill where no card covers it`. ## Gates (local, at `b596a0d`) - `node scripts/check-changeset-presence.mjs`: exit 0. 5 source files of 1 released package; 1 changeset declared. - `pnpm check:new-line-citations`: exit 0. `VERDICT new-cross-file-line-citations: 0 new citation(s)`. - `pnpm check:control-bytes`: exit 0. - `pnpm check:changeset-claims`, `pnpm check:pending-changeset-literals`, `node scripts/check-changeset-no-major.mjs`: exit 0 each. Repo-wide lint and the full test farm are CI's. ## Acceptance notes - **Pill past the frame.** A lane beside a card in the leftmost column runs inside the diagram's 28px padding (x 4 when the column starts at 28). The pill is centred on the lane, though, so a long pill reaches up to half its width left of the frame that `size` describes. Fit-to-view frames `size` only, so when the fit is limited by width the pill's left part can sit off-canvas. Back-edge pills already reach past the right side the same way. Not changed here. - **Crowded pill band.** A pill moved below its source shares that band with its sibling branches' pills. `layoutBranchLabels` narrows each pill to the distance to its neighbour minus 8px. In reading C that is 63px, so the pill reads "if recor…" and the full wording stays in its tooltip. In edit mode an insert handle, which always sits to the right of its own pill, can overlap the next pill. That handle rule is the existing one: a three-way decision with long labels hits it today. - **No upward detour.** A forward edge whose target is not below its source (possible only with manual positions) is never detoured, because there is no room going down. It keeps `edgePath`. - **Short first turn.** The first turn takes half the open band below the source (at most 28px), and the pill gets the other half. With the 56px auto-layout gap, a 144px sideways turn happens over 28px, which reads as a short hook. - **README left as is.** The `Visual flow canvas` example in `packages/app-shell/README.md` has exactly this shape: decide → end, `isDefault`, label "Skip", past the email node. Its prose ("rendered as labels on the connectors") is still true, so the README is not edited; it is outside this claim's file surface. - **Findings filed:** none. ## Files (lines added / removed against `5dbc6a5`) - `packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts`: +256 / -0 - `packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx`: +45 / -7 - `packages/app-shell/src/views/metadata-admin/previews/flow-region-view.tsx`: +10 / -3 - `packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.test.ts`: +257 / -0 - `packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.skipEdgeRoute-13144.test.tsx`: +252 / -0 - `.changeset/13144-decision-skip-edge-route.md` (`@object-ui/app-shell` patch) Session: `https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG` (dispatched by seat `domain:ui#3`, claim comment 6285215359). --- _Generated by [Claude Code](https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
#13193) Fixes #13129 Clause-②: no ## What `AdvancedChartImpl` passed no `tick` to any axis it draws, so recharts painted every tick label in the axis `stroke` default, `#666` (about 3:1 on a dark card). Every axis the advanced chart draws now takes one shared tick style, `AXIS_TICK_STYLE = { fill: 'hsl(var(--muted-foreground))' }`, the same fill `ChartImpl.tsx` uses: - the category x axis, through `xAxisCommonProps` (used by the combo branch and the vertical cartesian branch); - the combo's two value `YAxis`; - the horizontal-bar branch's value `XAxis` (single and dual) and its category `YAxis`; - the vertical cartesian branch's value `YAxis` (single and dual); - scatter's `XAxis` and `YAxis`; - radar's `PolarAngleAxis` and `PolarRadiusAxis`. Only the fill is set; the font size stays the container's `text-xs`, and every other tick prop is unchanged. Mechanism, measured in the rendered DOM: `ChartContainer` already carries `[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground`, but recharts 3 draws tick text under `g.recharts-cartesian-axis-tick-label` (in a separate z-index layer), while `g.recharts-cartesian-axis-tick` now holds only the tick `line`s. That selector matches no text, which is why the computed fill was `#666`. Polar axes match neither. ## Tests (at HEAD 6ab7bd8720) - New pin `packages/plugin-charts/src/AdvancedChartImpl.axisTickFill-13129.test.tsx`: for bar, line, area, bar with two value axes, horizontal-bar (single and dual), combo, scatter and radar, it counts the drawn axes and asserts every tick `text` of each axis has `fill="hsl(var(--muted-foreground))"` and not `#666`. Result: 9 passed (9). - Ablation (via `ablation-replace.mjs`, wrap mode): anchor `const AXIS_TICK_STYLE = { fill: 'hsl(var(--muted-foreground))' };` replaced with `const AXIS_TICK_STYLE = undefined;` (anchor x1 to x0, blob e5b9d2f to 9f9f650ede13). Result: 9 failed (9), for example `recharts-xAxis-tick-labels#0 tick fill: expected '#666' not to be '#666'`, and `angleAxis#0 tick fill: expected '#808080' ...` on radar. Restore: blob after restore e5b9d2f equals the blob at HEAD, and `git diff HEAD` is empty. - All 30 `AdvancedChartImpl.*` test files: 544 passed. The 29 other plugin-charts test files that name `AdvancedChartImpl`: 497 passed. The 4 plugin-dashboard tests that read tick DOM: 68 passed. - `pnpm --filter @object-ui/plugin-charts type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`; the test config includes `src/**/*.test.tsx`), after building the `@object-ui/plugin-charts^...` closure: exit 0. - eslint, narrowed to the 2 changed source files: 0 errors (the json output counts 2 files). The config sets no `parserOptions.project`, so type-aware linting is off and this diff cannot change the result for any file it does not touch. - `check:` phantom-deps, self-import, unreferenced-sources, doc-example-readers, handler-key-reads, side-effects-array, element-data-source-declaration, spec-symbols, action-forward-parity, designer-field-key-parity, icon-record-names, i18n-keys, i18n-drift, i18n-designer-parity, control-bytes, new-line-citations; `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`, `check-changeset-presence.mjs`: all exit 0. - The full `packages/plugin-charts/` directory is left to CI. ## Changeset `.changeset/13129-chart-axis-ticks.md`: `@object-ui/plugin-charts` patch. ## Acceptance notes - The `[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground` class on `ChartContainer` (`ChartContainerImpl.tsx`) matches nothing under recharts 3, for the reason above. It is now redundant for the advanced chart and was already dead. Not filed: it is dead code with no user-visible effect once the tick prop is set. Whoever next edits `ChartContainerImpl.tsx` can drop it or retarget it to `.recharts-cartesian-axis-tick-label`. Nobody has picked it up. - `ChartImpl.tsx` restates the same fill object on each of its axes. Left as is; it is outside this card's file surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…bove it (#13195) Fixes #13127 Clause-②: no ## What changed In a package with no objects, Studio's Data rail said "No objects yet — create one below to start". The rail draws nothing below that sentence: `StudioRail` renders its heading row (with the rail's only create control, the "+"), then the search box, then the list where the sentence sits. The sentence now names that "+", in the wording the Automations and Access rails already use for their own empty sentences (`engine.studio.auto.none`, `engine.studio.access.none`): | locale | before | after | |---|---|---| | en | No objects yet — create one below to start | No objects yet — click “+” above to create one | | zh | 还没有对象 — 在下方新建一个开始 | 还没有对象 — 点上方的「+」新建 | Only the value of `engine.studio.data.noObjects` changes. No key is added or removed, and nothing on the package entry moves. Changeset: `.changeset/13127-data-rail-empty-copy.md` (`@object-ui/app-shell` patch). ## Premise check against `main` at `961b69f` 1. `engine.studio.data.noObjects` renders inside the Data rail (`DataPillar` mounts `StudioRail` with `testId="studio-rail-data"`), in the list area under the heading row that carries the "+" (`addTestId="studio-rail-add"`). Confirmed: the "+" is above the sentence and nothing that creates is below it. 2. The sibling string that also says "below", `engine.studio.data.firstObjectHint` ("… Create one below; then design its fields …"), does not render in the rail. It renders in the content column's empty block, directly above that block's "New object" button (`data-testid="empty-state-new-object"`). Its pointer is right for where it renders, so it is unchanged (its zh value, "点击下方按钮创建一个", likewise). 3. The ten locale packs under `packages/i18n/src/locales/` do not carry this key. The designer's own table in `metadata-admin/i18n.ts` holds en and zh only (the `engine.*` carve-out its header describes), so those two rows are all that move; `check:i18n-drift`'s designer leg reports the zh row followed the en change. No other key of that table is touched. ## Test `StudioDesignSurface.emptyPackage.test.tsx` gains one case on the real `DataPillar` over an empty package: the rail's empty sentence is inside `studio-rail-data`, it names the "+" (en `“+”`, zh `「+」`), and the rail's `studio-rail-add` precedes the sentence in document order. Ablation (one-off, not kept), run with the fix committed at `b316b28`: the en value was put back to the old text through `objectstack:scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob `73bdf93cdf49` → `42f7baac5071`). The new case went red, `expected 'No objects yet — create one below to …' to contain '“+”'`, and the file's other three cases stayed green (`Tests 1 failed | 3 passed (4)`). Restore proven by blob == HEAD (`73bdf93cdf49`) and an empty `git diff HEAD`. ## Local gates, head `dfa0da6` - `pnpm exec vitest run` over the touched test file, the Automations/Access empty-state file, `studio-locale.i18n`, `zhPillarNames-11801`, `select-placeholder-literal-11252`, `block-config-i18n`, and the designer-parity and en-drift gate tests → `Test Files 8 passed (8)`, `Tests 121 passed (121)`, exit 0. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`; the test config lists the touched test file) → exit 0, after the dependency closure build. - `node scripts/check-changeset-presence.mjs`, `pnpm check:new-line-citations` (0 new), `pnpm check:control-bytes`, `pnpm check:i18n-keys`, `pnpm check:i18n-drift`, `pnpm check:i18n-designer-parity`, `pnpm check:i18n-dead-keys` (report), `pnpm check:changeset-claims`, `pnpm check:pending-changeset-literals`, `pnpm changeset:check` → each exit 0. Repo-wide lint and the full test farm are CI's. ## Selectors for a browser read - The Data rail: `[data-testid="studio-rail-data"]`. - Its "+": `[data-testid="studio-rail-data"] [data-testid="studio-rail-add"]`. - The empty sentence: `[data-testid="studio-rail-data"] p` (the only paragraph in the rail while the package has no objects; it has no test id of its own). ## Acceptance notes - Read-only packages (not changed here, and not made worse). When the package is read-only, the Data pillar passes no `onAdd` to `StudioRail`, so the rail draws no "+", yet the rail still renders `engine.studio.data.noObjects`; and the content column's block still shows `firstObjectHint` ("Create one below") while its button is hidden. The old text pointed below at nothing; the new text points above at nothing. objectui#12514 gave the Automations and Access rails the read-only posture "no rail pointer" (pinned in `StudioDesignSurface.emptyStates-12514.test.tsx`); the Data pillar did not get it. This is a source reading, not reproduced in a browser (it needs a non-writable package with no objects), and the render condition lives in `StudioDesignSurface.tsx`, outside this claim's file surface. Raised to the seat in the report; no carrier yet. - `check:i18n-dead-keys` lists `engine.studio.data.readOnlyPackage` ("This package is read-only — switch to or create a writable package to add objects") as a confirmed dead-key candidate: no render site in `packages/app-shell/src`. Not touched: it is another key of the same table. Session: `session_01GL347th7aWQUnY2k8diSLG` (dispatching seat; this run is its `mode:subagent` dev). --- _Generated by [Claude Code](https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ted hidden columns are not listed (objectui#13125) (#13196) Fixes #13125 Clause-②: no ## What changes The Studio **Pending changes** sheet (`DraftChangesPanel`), in the drill-in under an item row: - **Field rows lead with the field's label.** An added field now reads `+ Visit Date` with the machine name `visit_date` beside it in monospace, the way the item row already reads (#11862). Changed and removed rows read the same way. Added and changed rows show the label of the draft definition (what publishing installs); a removed row shows the published one's. A field with no label, or with a label equal to its name, reads as its name alone in monospace, exactly as before. The label is read the way the spec declares a field label, as a string: anything else, or a blank one, is no label. - **`__search` is no longer listed as an author change.** A field row is dropped when every definition of that field in the two bodies carries `system: true` and `hidden: true`. That is the Data pillar's existing rule, `isStudioHiddenSystemField` (#11780), imported rather than restated: no second list of internal names. Why `__search` appeared at all: the `/meta` read exits serve an object with the platform's materialization seam replayed onto it (objectstack metadata-protocol `materializeFromRegistry`, which calls the registry's `materializeServedObjectOnto`: injected system columns, the search companion, the title designation). The draft read is one of those exits. A NEW object has no published body, so every stamped column read as added. Where the rows' data comes from (measured before choosing): the drill-in (`EntryDetail`) already reads both item bodies on expand, and `computeChangeDetail` diffs them. The labels and the two marks are read off those same two bodies, so there is no new fetch. `computeChangeDetail` now also returns `fields.labels`, a Map from each listed field to its label (a Map because `__proto__` is a legal field name). ### Row selector for the browser reading Each field row is now `[data-testid="draft-entry-detail"] [data-testid="draft-field-row"]`: a `p` whose own text is the mark and the label, with the machine name in the `span.font-mono` inside it. The "Also changed:" key rows and the item row are unchanged. ## Scope - The changed and removed field rows get the same label-first row and the same filter as the added rows. The card's Expected says "Field rows lead with the field label" while the claim names the added-fields rows; it is the same component, the same defect class and one row helper, so the drill-in stays consistent. Declared here so the seat can judge it. - Not touched: the summary line (#13147), the field pickers (#13152), the "Also changed:" key rows, the item row. No package export, prop or i18n key is added. ## Behaviour change worth naming An UPDATE whose two bodies differ only in a column the rule hides now reads "No differences detected" once expanded, and drops its Update badge. The no-change mark (#11807) reads the same filtered diff, as it already reads the envelope-free pair (#12594). ## Acceptance notes - **Audit columns and `owner_id` are still listed on a NEW object** (`created_at`, `created_by`, `updated_at`, `updated_by`, `owner_id`). The platform injects them with `system: true` but without `hidden: true` (`AUDIT_FIELD_DEFS`, `OWNER_FIELD_DEF`), so the existing rule keeps them, and this PR does not widen that rule (dispatch instruction). They now read by label, for example `+ Created At` beside `created_at`. Whether "injected but not hidden" counts as an author change in this sheet is a product decision, not this card's. Carrier: the dispatching seat (`domain:ui` seat 3). - Seen while testing, not fixed here: a field literally named `__proto__` never reaches the field diff, because `diffFields` (metadata-admin `object-fields-io`) collects its `byName` in a plain object. The spec's field-name grammar admits that name. No public-door reproduction was taken, so no card. Carrier: none. ## Tests New: `packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldRows-13125.test.tsx` (11 tests). Its fixtures copy the platform literals the #11780 pin copies (`provisionSearchCompanion`, `OWNING_BUSINESS_UNIT_FIELD_DEF`, the `created_at` row of `AUDIT_FIELD_DEFS`), with that pin's three controls: `internal_note` (hidden, not system: listed), `owner_id` (system, not hidden: listed), `created_at` (audit column: listed). Ablations, one per changed behaviour. The fix was committed first (`7ed0889`); each mutation went through objectstack's `scripts/ablation-replace.mjs` in wrap mode, which proved the anchor hit once and the blob changed, and proved the restore (blob equal to HEAD, `git diff HEAD` empty): 1. The hidden-column filter replaced by `return true;`: 4 failed, 7 passed. The four hidden-column assertions went red; the "marks one body lacks" control stayed green, as it should. 2. The label lead replaced by `const lead = undefined`: 3 failed, 8 passed. The three render assertions went red; the pure label tests stayed green (they read `computeChangeDetail`, not the row). Local runs on head `e1ac777`, exit codes captured before any pipe: - `pnpm exec vitest run packages/app-shell/src/preview/`: exit 0, 29 files, 211 tests passed. - `pnpm exec vitest run` on four Studio files that render the real panel or pin the rule (`StudioDesignSurface.publishPosture-12517`, `StudioDesignSurface.publishCount-11807`, `DataPillar.hiddenSystemFields-11780`, `DraftPreviewBar`): exit 0, 4 files, 25 tests passed. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`; the test project includes `src/**/*.test.tsx`): exit 0. - `node scripts/check-changeset-presence.mjs`: exit 0. - `pnpm check:new-line-citations`: exit 0, 0 new citations. - `pnpm check:control-bytes`: exit 0. No test outside `packages/app-shell/src/preview/` expands the drill-in: a `git grep` for `draft-entry-detail`, `draft-entry-toggle` or `computeChangeDetail` over every package's tests hits 10 files, all in that directory. Repo-wide lint and the full test farm are CI's. Changeset: `.changeset/13125-pending-field-rows.md`, `@object-ui/app-shell` patch. Session: `https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG` --- _Generated by [Claude Code](https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Contributor
Author
|
GO — the Tier H sitting is brought forward to NOW (maintainer ruling, Tesla 2026-10-25 22:42Z: 「开始,接受绕过」). This PR leaves draft on head Verification (run 6, head
|
os-zhuang
approved these changes
Oct 25, 2026
os-tesla
pushed a commit
that referenced
this pull request
Oct 25, 2026
main now carries ui/ (cutover #25825) and the dual-mode sdui gates (#25833). .changeset/config.json keeps this branch's side ("object-ui" in the fixed group, which the root PRIVATE_FIXED_MEMBERS rule accepts); scripts/ui-import/README.md (added on both sides) keeps the cutover's splice procedure first and 4a's record after it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe
os-tesla
pushed a commit
that referenced
this pull request
Oct 25, 2026
…711) With ui/ on main (#25825), the objectui side of the release lane lands as tree edits instead of a patch file: - README step 2: the pin, the dist-injection probes, objectui's release lane, its Changesets config, the QUICK_REFERENCE release sync (17c) and the nested pull_request_template.md are deleted; objectui's 65 pending changesets stay under ui/ for #25831; - 4a-ui-side.patch applied under ui/ (git apply --check exit 0 on this tree), then deleted; scripts/ui-import/README.md now records what was applied; - the SDUI manifest is regenerated from ui/ for the merged tree (107 -> 106 components; objectui#13449 retired html), from the patch the 4b PR 2a thread measured. check-changeset-fixed, check:lockstep-package-count, check:sdui-lockstep and check-sdui-manifest (--self-test and the run) exit 0. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe
This was referenced Oct 25, 2026
os-zhuang
pushed a commit
that referenced
this pull request
Oct 25, 2026
…s the fixed group, @objectstack/console builds from ui/apps/console, the objectui pin is deleted (#25711) (#25745) <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6JaoKAHLK79xmvJyPStopRpPQ2)_ Part of objectstack#25677 Fixes #25711 Blocked-by: sub-issue 5 of #25677 (the subtree PR that adds `ui/`; not filed yet) Clause-②: no Metadata-protocol: none **Maintainer rulings (Tesla, 2026-10-25 09:06Z, director thread):** 17a — the release npm token has publish rights on `@object-ui`. 17b, ruling B — the private VS Code extension `object-ui` stays in the one fixed group. 17c, ruling B — the QUICK_REFERENCE release-sync rows retire; decision B (17c) carried here. > ⛔ **Tier H, cutover sitting.** The cutover #25825 is on `main` (`fd40831416`, with #25833). This PR merged `main` and applies its `ui/` half as tree edits on that tree. Local verdict on `fb110c2323`: comment 6404553650, every step and gate exit 0. It merges per ruling 30 = B once that sweep is clean and the four fast lanes are green. > Body refreshed 2026-10-25T22:55Z on head `fb110c2323` (the merge of `main` `fd40831416` is `e2db258a24`). Comments read through 6404553650. ## What changes **1. One fixed group** (`.changeset/config.json`). The 38 public `@object-ui/*` packages and the private VS Code extension `object-ui` (17b) join the `@objectstack/*` fixed group; `@object-ui/example-*`, `@object-ui/site`, `@object-ui/runner` and `@object-ui/test-support` are ignored, as in objectui. (objectui#13506 made `@object-ui/runner` private before the freeze at `9e1fe03286`.) `ui/.changeset/config.json` is deleted by the ui-side patch (changesets reads one config per repository), and objectui's 65 pending `ui/.changeset/*.md` stay under `ui/` for aftercare #25831. Measured on cutover head `c04b36dcb9`, moving them as they stand reds `check-empty-changeset` (3 empty frontmatters) and `check-adr-0087-registration` (41 breaking changesets with no disposition). Root `check-changeset-fixed` accepts a private name in the group only when `PRIVATE_FIXED_MEMBERS` declares it (`object-ui` alone, with its reason), the package is private, and `privatePackages.version` is true; it reds if that member leaves the group. `check:lockstep-package-count` subtracts the declared private members, so the published count is 111. `check-changeset-no-major` now reads `packages/console/build-dist.sh` as the Console's packed-copy build script, and `check-empty-changeset`'s frontmatter-parser family loses the deleted objectui digest. **2. `@objectstack/console` builds in this workspace.** `packages/console/build-dist.sh` copies `ui/apps/console/dist` (built by turbo through `@object-ui/console#build`) into `packages/console/dist`, ships the tracked `sdui.manifest.json` (ADR-0080), and runs the single-zod canary over the bytes it ships. Root task `@objectstack/console#build:dist` (`turbo.json`) depends on `@object-ui/console#build`; `pnpm console:build` runs it, and `pnpm release` / `cut-rc.yml` run it after `pnpm run build`. `files` and `prepublishOnly` are unchanged. `scripts/build-console.sh` is deleted. The CLI's remedy for a missing Console dist names `pnpm console:build`. **3. The objectstack-side pin is deleted.** `.objectui-sha`, `scripts/bump-objectui.sh` (+ selftest), `scripts/objectui-changeset-digest.mjs`, `scripts/objectui-range.mjs`, `scripts/check-prerelease-pin-watch.mjs`, `scripts/check-console-sha.mjs`, `scripts/check-console-injection.mjs`, `scripts/assert-console-spec-injection.mjs`, `scripts/console-spec-probes.mjs`, `packages/spec/scripts/build-console-objectstack-root.test.ts`; the pin reads in `cut-rc.yml` ("THE objectui PIN IS AN INPUT HERE" and `Read the committed objectui pin`), `release.yml`, `lint.yml`, `showcase-smoke.yml` and the root `package.json` scripts (`objectui:*`, `check:console-sha`, `check:console-injection`, `check:objectui-*`, `check:prerelease-pins`; `pnpm dev*` no longer runs `check:console-sha`). `scripts/check-console-intercept-disarm.mjs` stays. Release notes (`collect-release-notes.sh`) say "no objectui pin" when the new ref has none. Gate self-tests and rosters that named the deleted scripts follow (`dispatch-gates`, `pnpm-filter-targets`, `check-pnpm-filter-targets` (tree-aware: foreign `@object-ui/` targets must be 0 without `ui/`, >0 with it), `bare-root-worklist`, `check-type-check-coverage`). The lockstep count in `content/docs/protocol/backward-compatibility.mdx` and `scripts/publish-smoke-pack.mjs` reads 112, both scopes (`check:lockstep-package-count --fix`), and `packages/spec/vitest.repo-tests.json` drops the deleted test. **4. The objectui-side pin is deleted, in `ui/`.** Until `ui/` was on `main`, this half was carried as `scripts/ui-import/4a-ui-side.patch`. After the cutover landed, `fb110c2323` applied it under `ui/` with README step 2's deletions, then deleted the patch file. `scripts/ui-import/README.md` now records what was applied. It deletes `.objectstack-sha`, `.objectstack-pin/`, `bump-objectstack.sh`, `objectstack-pin.mjs`, `objectstack-floor.mjs`, `check-spec-range-floors.mjs`, `check-installed-spec-pin-claims.mjs`, the dist-pin Vite probes `vite-objectstack-spec-dist.ts` / `vite-objectstack-client-dist.ts`, `changeset-release.yml`, `spec-range-floors.yml`, `release-github-releases.mjs` and their tests, plus (17c) `sync-quick-reference-release.mjs`, its test and the `quick-reference-current-release-4143` pin, with `QUICK_REFERENCE.md`'s version rows. The single-zod guard moves to `ui/scripts/vite-single-zod.ts` (it runs in every console build); `apps/console/vite.config.ts` drops both injections. Every reader of the Changesets config goes through a new `changesetConfigPath()` (own root standalone, monorepo root when nested); objectui's `check-changeset-fixed` and `check-unused-dependencies` otherwise keep their extension rules (17b). The patch was written against verify head `9e37acd126` and checked with `git apply --check` on cutover heads `1c0d02eda4` and `c04b36dcb9` and on `main` at `fd40831416`. It also deletes the nested `ui/.github/pull_request_template.md`, which never renders and whose only markdown-test-inputs reader was the claims test. **6. SDUI manifest.** Carries the sdui manifest regeneration for the merged tree (107 → 106 components; objectui#13449 retired html): `sdui.manifest.json` and `scripts/sdui-manifest.record.json`, regenerated from `ui/` now that `.objectui-sha` is gone and the dual-mode gates (#25833) read the in-tree oracle. **5. One release lane.** objectui's `changeset-release.yml` is gone; root `release.yml` publishes both scopes in one `pnpm -r publish`, and the root `scripts/release-github-releases.mjs` cuts one GitHub Release per package tag across both scopes. ## The four deleted Console checks, and what proves the same thing now | Deleted | What it proved | Now | |---|---|---| | `assert-console-spec-injection.mjs` | the built Console bundles THIS tree's `@objectstack/spec`, not the copy objectui's lockfile installed | Can no longer be false. Every `@objectstack/*` dependency under `ui/` is `workspace:^` (33 spec, 2 client, 2 lint, 2 formula rows measured), which pnpm links and never resolves from the registry; the merged lockfile has 0 registry entries for `@objectstack/spec` or `@objectstack/client` and 37 `link:` rows to `packages/spec`. The single-zod guard (Vite, `ui/scripts/vite-single-zod.ts`) and canary (`build-dist.sh`, on the shipped bytes) still run in every build. | | `check-console-injection.mjs` | a Console dist restored from the pin-keyed cache was built against the current spec | Can no longer be false. The cache keyed on `.objectui-sha` is gone; turbo's hash for `@object-ui/console#build` includes `@objectstack/spec#build`, `@objectstack/client#build` and `@objectstack/lint#build` (measured with `turbo run build:dist --filter=@objectstack/console --dry=json`), so a spec change misses the cache. | | `check-console-sha.mjs` | the local `packages/console/dist` was built from the pinned objectui commit | The pin it compared against is gone, so that drift cannot occur. A local dist can still be older than `ui/` after a pull; `pnpm console:build` rebuilds it (a turbo cache hit when nothing changed). Nothing checks this on `pnpm dev` any more: reported to the dispatcher. | | `console-spec-probes.mjs` | the shared probe derivation for the two injection checks above | Left with them; nothing else imported it. | ## Verification (merged tree at objectui@cd70b7818; the 17b/17c items re-measured on verify head `9e37acd126`) - `pnpm changeset version` on the cutover head `1c0d02eda4` (objectstack#25825, objectui frozen at `9e1fe03286`) + this PR + the ui patch: exit 0, and all 112 members move 18.1.0 → 18.2.0 together (111 public: 73 `@objectstack/*` and 38 `@object-ui/*`, plus `object-ui`). The private, ignored `@object-ui/runner` stays at 18.1.0. On that tree root and objectui `check-changeset-fixed` and `check:lockstep-package-count` exit 0. - Earlier, on verify head `9e37acd126` (when `@object-ui/runner` was still public): all 113 members move 18.1.0 → 18.2.0 in one bump: 112 public (73 `@objectstack/*`, 39 `@object-ui/*`) and the private extension `object-ui`. The other private `@objectstack/*` packages move as they do on `main`. `pnpm -r publish` and `release-verify-npm` skip `object-ui` (private), so nothing it owes the registry changes. - `pnpm -r publish --dry-run` after the version (at `9e37acd126`): 112 packages, 39 `@object-ui/*` + 72 `@objectstack/*` + `create-objectstack`, all 18.2.0. `pnpm pack` of `@object-ui/core`: `@objectstack/spec` `workspace:^` → `^18.2.0`, `@object-ui/types` `workspace:*` → `18.2.0`. - `pnpm console:build` (CI=true): exit 0, single-zod canary green. The packed `@objectstack/console` has the same top-level `dist/` entries as the published 18.1.0 tarball, minus `dist/.objectstack-injection.json` and `dist/.objectui-sha`; assets 2,268 → 2,381 (newer objectui), tarball 9,260K → 9,140K. - Console eager-closure budget (`ui/scripts/check-eager-closure-budget.mjs`, ceiling 3,496.0 KB): this build 3,054.2 KB gzipped, `vendor-objectstack` 1,605.2 KB; the published 18.1.0 dist measured by the same ceilings: 3,522.3 KB, `vendor-objectstack` 1,851.5 KB. The membership half is red identically on both (`packages/core`: 1 of 89 modules in `vendor-objectstack`), so it predates this PR. - The ui-side patch on verify head `9e37acd126`: `git apply --check` passes after README steps 1–2 and reproduces the edited `ui/` tree byte for byte (60 files). With this PR's root half, install and `pnpm run build`: `type-check:scripts`, `type-check:vitest-config`, `lint:root` exit 0; root and objectui `check-changeset-fixed` pass; `vitest run scripts/__tests__` 17 failed / 5,573 passed, where 2 are 15 s load timeouts that pass alone, 2 fail the same way without the patch (`check-lockfile-integrity`; `quick-reference-commands-4149` on the root scripts the adaptation drops, cutover bill), and 14 read objectui's nested `ui/.github/`, which 4b PR 2 deletes. - actionlint 1.7.7 over `release.yml`, `cut-rc.yml`, `lint.yml`, `showcase-smoke.yml`: exit 0. No step reads a pin file. ## Gates (this branch, no `ui/`) `dispatch-gates --commands` derives 166 families for this diff; all 166 ran, and `dispatch-gates --ran` reconciles: 164 run, 2 NOT-MEASURED. All were run on head `4ac569dc00` (after merging `main` at `f0cf338330`, which had moved `.objectui-sha` and the root `build` script), after a full `pnpm run build`. 156 exit 0, including `check:required-contexts`, `check:workflow-status-functions`, `check:workflow-step-name-quoting`, `check-changeset-no-major`, `check-empty-changeset`, `check-adr-0087-registration`, `check:lockstep-package-count` (112) and `check:nul-bytes`. One is red on `main` itself and not this PR's: `check:liveness` (`translation/flows.screens` cites `service-automation/src/engine.ts`, which #25734 refactored; this PR touches neither). The other 9 are red or not measured because `ui/`, PR B or 4b has not landed yet, as designed: | Gate | Exit | Why red here | Clears when | |---|---|---|---| | `check-changeset-fixed` | 1 | the 38 `@object-ui/*` names and `object-ui` in `fixed` have no package without `ui/` (on cutover head `1c0d02eda4` + this PR + the ui patch: exit 0, 111 public + 1 declared private) | sub-issue 5 | | `check-prerelease-pin-watch --self-test`, `--verbose` | 1, 1 | named only by `prerelease-pin-watch.yml`; this PR deletes the script | PR B of #25697 | | `check-self-test-wired --self-test` | 1 | `ci.yml` `console-pin` and `prerelease-pin-watch.yml` still invoke deleted scripts | PR B of #25697 | | `check:pm-dispatch-gates` | 1 | 3 of 1,913 fast-tier cases, all on the same live invocations of deleted scripts | PR B of #25697 | | `check:console-sha`, `check:console-injection` | NOT-MEASURED (254) | root scripts deleted here; only the `console-pin` job names them | PR B of #25697 | | `check-sdui-manifest`, `check:sdui-lockstep` | 1, 1 | they read `.objectui-sha` | 4b PR 2 (#25712) | ## Depends on, outside this PR - **Sub-issue 5** (subtree PR): adds `ui/`; this PR merges right after it. - **PR B of #25697** (retire the pin gates in the cutover window): removes the `console-pin` job and `console` filter in `ci.yml`, `prerelease-pin-watch.yml`, and the live specimens in the `dispatch-gates` self-test that still name `check-prerelease-pin-watch.mjs`. Until it lands, `prerelease-pin-watch.yml` runs a script this PR deletes. - **4b PR 2 (#25712)**: `check-sdui-lockstep`, `check-sdui-manifest` and `gen-sdui-manifest-node` read `.objectui-sha` and go red without it; regenerating `sdui.manifest.json` from `ui/` is theirs (the root copy lags the old pin, and `build-dist.sh` ships the tracked one). Also the nested `ui/.github/` and the tests that read it, and both AGENTS.md files. - **Maintainer**: the npm token's `@object-ui` publish rights (card risk 1) — confirmed by Tesla, ruling 17a. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --------- Co-authored-by: Claude <noreply@anthropic.com>
os-tesla
pushed a commit
that referenced
this pull request
Oct 25, 2026
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6SEitNngAMugGbuJHWsDS
This was referenced Oct 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Tesla · project thread
Part of objectstack#25752 (child 5, the cutover procedure; steps 3–4). Part of objectstack#25677.
Clause-②: no
Metadata-protocol: none
⛔ Merged by the maintainer at the Tier H sitting, 2026-10-26 09:00Z (北京 17:00) — not before
This PR is Tier H by size (the whole objectui tree, ~2.6 M lines) and is the first of four merged in order at the sitting: this PR → 4a #25745 → PR B (objectstack#25697 item 6) → 4b PR 2 #25712, then the required-checks change (step 6). It stays draft until the seat says it is green; the seat never enqueues it. Reviewers: the GOVERNED_APPROVERS, requested when it leaves draft.
What this PR is
objectui (objectstack-ai/objectui) becomes the
ui/workspace of this repository, per the maintainer's D1 ruling (objectstack#25677: one repository, one fixed version group, the objectui repository archived as a migration notice).git subtree add --prefix=uiof objectuimainat its frozen tip9e1fe032866965c7cb0460cff2b6af09350236b7(objectui#13545, the final.objectstack-shapin, pointing at objectstack5e7cf63e71). objectuimainis frozen since 2026-10-25 15:40Z (freeze notice: objectstack#25752); nothing lands there after that commit. History is kept (no squash).overridesandpatchedDependenciesfrompnpm-workspace.yamlonly, so objectui'spnpmfield moves there: the qs / shell-quote / tmp / uuid floors, the single react line (19.3.0), the@radix-ui/react-focus-scope@1.1.16patch; the react-router override narrows to the 8.x line it fixes (objectui's react-router-dom 7.18 pins a 7.x react-router with no fixed version to force).@objectstack/*range in the 32 ui manifests becomesworkspace:^;ui/stops being a workspace root of its own (nestedpnpm-workspace.yamldeleted,pnpmfield andpreinstallpin check removed,turbo.jsonparked asturbo.objectui.json); the ui root manifest drops its turbo-recursive scripts;scripts/ui-import/splice-lockfile.mjscarries objectui's lockfile resolutions into the merged lock. Drop 2 (root gates, ledgers, eslint ignore, skills move, the K1 scoping ofcheck:published-files) follows in this PR before it leaves draft; the list is in/mnt/project-files/d1-merge/out/cutover-adaptation/SERIES.mdand will be mirrored here.ui/pnpm-lock.yamlis removed; the root lock is objectui's resolutions spliced in (moved 0against objectui's own lock at the splice), reconciled twice withpnpm install --lockfile-only, thenpnpm dedupe;pnpm install --frozen-lockfileandpnpm dedupe --checkexit 0.ai7.0.118 and@ai-sdk/react4.0.121 hold one copy each.What this PR does not do
.github/workflows/**,AGENTS.md,docs/adr/**or CODEOWNERS: the pin-gate retirement is PR B, the release lane is 4a release(ui-merge): one release lane for both scopes — @object-ui joins the fixed group, @objectstack/console builds from ui/apps/console, the objectui pin is deleted (#25711) #25745, the workflow port is 4b PR 2 tooling(ui-merge): governance in-repo — liveness evidence and spec tests stop citing objectui by commit, one sdui-parser, AGENTS.md / pm scripts dropobjectui#,ui/AGENTS.mdnests, 18 governance workflows merged or retired (parent #25677, 4b/6) #25712, the governance forui/landed in tooling(pm): dispatch-gates git maxBuffer for the merged tree; register matchers proven on the nested ui/ rows (4b PR 1, inert) #25732.packages/consolepin field or its build switch (sub-issue 4).@object-uimanifests tocheck:published-files(maintainer ruling K1: the gate is scoped offui/here; aftercare objectstack#25753).Accepted red classes (procedure #25752, "Accepted red classes") and the bill
Anything red on this PR is read against
/mnt/project-files/d1-merge/out/5-cutover-bill-notes.md(items 1–36). A red that is not on the bill stops the sitting until it is ruled.Verification
(filled by the seat before the PR leaves draft: the CI run, the local gate sweep, the lock verdicts and sha, the
git amacceptance of the series.)🤖 Generated with Claude Code
https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU