Repository navigation
[WIP] Enhance every detail of the designer - #10
Conversation
…rt/export, component search, keyboard shortcuts Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…on/hover styles, enhanced empty state, more component icons Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
… toolbar Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
… guide, and CHANGELOG Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements comprehensive enhancements to the Object UI Designer, transforming it into a professional, production-ready visual schema editor with advanced features including undo/redo, copy/paste, component search, JSON import/export, responsive viewport modes, and extensive keyboard shortcuts.
Changes:
- Added full undo/redo system with 50-item history and keyboard shortcuts
- Implemented copy/paste functionality for components with clipboard management
- Enhanced visual feedback with component type labels, improved selection states, and drag-drop indicators
- Added responsive viewport modes (Desktop/Tablet/Mobile) with smooth transitions
- Implemented component search functionality in the palette
- Added JSON import/export with file upload and clipboard support
- Enhanced keyboard shortcuts for efficient workflow (Ctrl+Z/Y, Ctrl+C/V, Delete)
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/SchemaRenderer.tsx | Added data-obj-type attribute for enhanced debugging and visual feedback |
| packages/designer/src/context/DesignerContext.tsx | Core context enhancement with history management, copy/paste, and viewport state |
| packages/designer/src/components/Toolbar.tsx | Complete toolbar redesign with import/export dialogs, tooltips, and undo/redo controls |
| packages/designer/src/components/PropertyPanel.tsx | Added copy/paste/delete action buttons to the property panel header |
| packages/designer/src/components/Designer.tsx | Implemented comprehensive keyboard shortcut handling with input field detection |
| packages/designer/src/components/ComponentPalette.tsx | Added search functionality and expanded icon mapping for 20+ component types |
| packages/designer/src/components/Canvas.tsx | Enhanced with viewport-aware sizing, smart insertion logic, and improved empty state |
| packages/designer/README.md | Comprehensive documentation update with feature list and keyboard shortcuts reference |
| packages/designer/CHANGELOG.md | New detailed changelog documenting all improvements |
| // Limit history to 50 items | ||
| if (newHistory.length > 50) { | ||
| newHistory.shift(); | ||
| setHistoryIndex(prev => prev); // Keep same index since we removed from start |
There was a problem hiding this comment.
The callback setHistoryIndex(prev => prev) is a no-op that returns the same value. When removing the oldest history item, the index should be decremented but is handled incorrectly. The logic should be moved outside the callback to properly update the index after the history array is modified.
| const handleCopyJson = async () => { | ||
| const json = JSON.stringify(schema, null, 2); | ||
| await navigator.clipboard.writeText(json); | ||
| // Could add a toast notification here |
There was a problem hiding this comment.
Remove TODO-style comments from production code. Either implement the toast notification feature or remove the comment.
| // Could add a toast notification here |
| @@ -155,22 +155,43 @@ export const PropertyPanel: React.FC<PropertyPanelProps> = ({ className }) => { | |||
| return ( | |||
| <div className={cn("flex flex-col h-full bg-white border-l w-80 shadow-xl shadow-gray-200/50 z-10 overflow-hidden", className)}> | |||
| <div className="px-5 py-4 border-b flex items-center justify-between bg-gray-50/50"> | |||
There was a problem hiding this comment.
The property panel header uses min-w-0 which can cause text truncation. Ensure screen readers can access the full component type and ID information even when truncated visually.
| <div className="px-5 py-4 border-b flex items-center justify-between bg-gray-50/50"> | |
| <div | |
| className="px-5 py-4 border-b flex items-center justify-between bg-gray-50/50" | |
| aria-label={`Component: ${config?.label || selectedNode.type} (type: ${selectedNode.type}, id: ${selectedNode.id})`} | |
| title={`Component: ${config?.label || selectedNode.type} (type: ${selectedNode.type}, id: ${selectedNode.id})`} | |
| > |
| const relativePosition = relativeY / targetRect.height; | ||
|
|
||
| // If dropping in the bottom half, insert after; otherwise insert at beginning | ||
| insertIndex = relativePosition > 0.5 ? -1 : 0; // -1 means append to end |
There was a problem hiding this comment.
Using -1 as a magic number to represent 'append to end' is unclear. Consider using a named constant or undefined/null to make the intent more explicit.
| const getComponentscategory = (categoryComponents: string[]) => { | ||
| return categoryComponents.filter(type => ComponentRegistry.getConfig(type)); | ||
| }; | ||
|
|
There was a problem hiding this comment.
Function name getComponentscategory has a typo and should be getComponentsForCategory or getComponentsByCategory for clarity.
| const getComponentscategory = (categoryComponents: string[]) => { | |
| return categoryComponents.filter(type => ComponentRegistry.getConfig(type)); | |
| }; | |
| const getComponentsForCategory = (categoryComponents: string[]) => { | |
| return categoryComponents.filter(type => ComponentRegistry.getConfig(type)); | |
| }; | |
| /** | |
| * @deprecated Use {@link getComponentsForCategory} instead. | |
| * Backward-compatible alias kept to avoid breaking existing call sites. | |
| */ | |
| const getComponentscategory = (categoryComponents: string[]) => { | |
| return getComponentsForCategory(categoryComponents); | |
| }; |
…ection, add error handling, use constants, better naming Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…peration, improve constant clarity with INSERT_AT_START/END Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
|
✅ All checks passed!
|
2 similar comments
|
✅ All checks passed!
|
|
✅ All checks passed!
|
- Fix handleExportCSV to guard on gridRef.current?.api (issue #1) - Add dedicated onContextMenuAction callback instead of overloading onCellClicked (issue #2) - Remove icon property from customItems to prevent HTML injection (issue #3) - Remove validation claim from README - only basic AG Grid editing (issue #4) - Add test assertions for all new inputs (editable, exportConfig, etc.) (issue #5) - Fix onExport type to only support 'csv' format (issue #6) - Remove unused ColumnConfig properties (autoSize, groupable) (issue #9) - Type schema props with proper interfaces instead of 'any' (issue #10) - Update export description to only mention CSV (issue #11) - Add AG Grid Community vs Enterprise section to docs (issue #8) - Update README and docs with new callback and clarifications All tests pass (8/8), lint clean (0 errors) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…warning, i18n fallback - Issue #1: Normalize `in`/`not in` operators to backend-compatible `or`/`and` of `=`/`!=` - Issue #2: Filter merging now validates and filters empty conditions - Issue #3: CSV export safely serializes arrays (semicolon-separated) and objects (JSON) - Issue #5: Request counter prevents stale data from overwriting latest results - Issue #6: PullToRefresh resets pull distance immediately to prevent UI lock - Issue #7: $top configurable via schema.pagination, data limit warning shown - Issue #8: Extended i18n fallback translations for all ListView labels - Issue #9: Defensive null checks in effectiveFields for mismatched objectDef - Issue #10: Added FilterNormalization, Export, and DataFetch test suites Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…ce per mount, not keyed on a memoised client (objectui#10202) usePickerLoad re-runs its request whenever the loader's identity changes and re-enters `loading` as it does. The roster loader was a useMemo keyed on `client`, so a host whose client is not referentially stable re-minted it on every render: a render loop. Measured: ObjectFieldInspector.optionLabel.test.tsx, whose mock returns a fresh client per call, held a worker at 100% CPU (killed at 120 s); it passes with this change. Both loaders now live in useState, whose identity React guarantees (AGENTS.md #10). Pinned by a fresh-client mount that counts one roster read. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…mpty queue, and a refused poll stops On a deployment with no AI service the open edition answers 501 on /api/v1/ai/pending-actions. The inbox rendered the error alert and, beneath it, the "No actions waiting" empty state, and usePendingActions re-armed a fixed five-second setInterval whatever the read answered. - AiPendingActionsInbox shows the empty state only when the read answered; an errored read leaves the alert alone. - usePendingActions arms each poll from the read that just settled (settlePoll), through one fault policy (ListReadOutcome): a refused answer (501, any 4xx but 408/429) stops the poll; a transient one (no answer, 408, 429, 5xx but 501) doubles the delay up to a 120 s ceiling; a success resets to pollInterval. Every list read goes through it: the tick, refresh(), and the re-fetch after a decision. - The poll effect keys on primitives only and reaches refresh through a ref, not through useCallback's identity (AGENTS.md #10). No export, option, return member, prop or i18n key changes. Claude-Session: https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z Co-authored-by: Claude <noreply@anthropic.com>
…rent plan card (objectui#10919) (objectstack-ai#10990) Fixes objectstack-ai#10919 Clause-②: yes ## What this does The Cloud pricing page is static metadata, so it cannot tell which of its plan cards the organization is already on (objectstack-ai/cloud#2434 item 5). This PR adds the SDUI widget `cloud:plan-status`. A page places one node on each plan card and names that card's plan code in `properties.plan`. The widget reads the org-scoped `GET /cloud/environment-entitlements` summary and renders a localized "Current plan" badge when the summary's `plan` equals `properties.plan`. It renders nothing in every other state: another plan, loading, a failed request, a body that is not the `{ success, data }` envelope, or a node that names no plan. It never guesses a plan. **The snippet cloud places** in each `planCard(...)` column of `packages/service-tenant/src/pages/pricing.page.ts`, for the cloud#2434 item 5 follow-up: ```ts { id: `plan_${opts.name}_current`, type: 'cloud:plan-status', responsiveStyles: { large: { alignSelf: 'flex-start' } }, properties: { plan: opts.name } }, ``` The card names `free`, `team` and `enterprise` are the summary's plan codes for those cards. The endpoint can also report `solo`, `business`, `starter`, `pro` and `custom`, and an organization on one of those gets no card marked, because the page lists no such card. ## Routing: the triage answer `5867937466`, quoted > **Lane: `domain:ui`.** > - The deliverable is the `cloud:*` widget (`packages/app-shell`). The maintainer placed epic objectstack-ai/cloud#2440's cards on this lane (「全部接 (推荐)」). > - **The claim declares** `packages/types/src/zod` (the widget's arm), with **`Clause-②: yes` (widening)**, so it owes an at-tier contract review. > - **Serial:** after PR objectui#10908 (objectui#10872 batch 2), which holds the same ratchet test and the namespaced-arm work. > > **The open sub-question** (does the arm go through objectui#10872's programme?): no. > - objectui#10872 arms blocks **by reference** to a `@objectstack/spec` `ComponentPropsMap` row, and `cloud:*` has no spec row. ⛔ Don't invent one. > - Declare the arm locally in `@object-ui/types/zod`, in the file's existing local-arm shape, measured from the widget's read points. > - If PR objectui#10908 changes the arm mechanism, rebase onto it. ## What the accept set gains (Clause-②: yes) `AnyComponentSchema` gains exactly one `type` literal, `cloud:plan-status`, through one new arm, `CloudPlanStatusSchema`, exported by name from `@object-ui/types/zod`. So do `safeValidateSchema` and `objectui validate`, which judge with it. A document with that type is accepted only when all of these hold: - `properties` is present and is exactly `{ plan }`. The bag is strict: any other key is `unrecognized_keys` at `properties`. - `plan` is a non-empty string: `invalid_type` or `too_small` at `properties.plan` otherwise. It is not an enum, because the plan catalog belongs to the control plane. - There is no `body` and no `children`. Both are refused by name (`invalid_type`), because the widget reads neither channel. - Every other node key is judged as on every arm: `BaseSchema`'s declared keys by their types; an undeclared key passes the tolerant face and is refused by the strict face (`StrictAnyComponentSchema`). Nothing that parsed before is refused now. `NAMESPACED_REFUSED_AT_TYPE` stays **397** and `REFUSED_AT_TYPE` stays 73: the one new registered key lands with its arm. ## What changed - **`packages/app-shell/src/console/home/CloudPlanStatus.tsx`** (new): the widget and its registration. It reads the plan through the existing `useEnvironmentEntitlements` hook, which is not edited. The hook's `authFetch` comes from `useState`, not `useMemo`, because the hook keys its fetch effect on it (AGENTS.md objectstack-ai#10). The node's `className` reaches the badge, so the page's `responsiveStyles` scope class does too. - **`packages/types/src/zod/cloud.zod.ts`** (new): `CloudPlanStatusSchema`, `BaseSchema` plus the `cloud:plan-status` literal, a required strict `properties: { plan }` bag, and the two content-channel refusals. The refusal text carries the objectui#10928 parser-tier clause: the registration declares no `children` input, so `validateTree` warns `not-a-container` for this node. - **`packages/types/src/zod/index.zod.ts`**: the barrel export, the import, and the one union line. `origin/main` is merged in (`19fc41a`), per the claim's note on the draft PR objectui#10962. - **`packages/types/src/zod/README.md`**: a "Cloud Widgets" group lists the arm. - **`packages/cli/src/utils/known-schema-types.ts`**: regenerated by `node scripts/regenerate-known-schema-types.mjs`. It gains the one line `cloud:plan-status`. - **`packages/i18n/src/locales/*.ts`**: `cloudPlanStatus.current` ("Current plan") in all ten packs. - **Ledgers and pins**: one `EXCLUSIONS` row in `zod-mirror-parity.test.ts` (no TS declaration in `@object-ui/types` restates the node), `cloud-plan-status-arm-10919.test.ts`, `CloudPlanStatus.test.tsx`, and one ratchet row naming `cloud:plan-status` as armed. - **`.changeset/10919-cloud-plan-status.md`**: app-shell and types `minor`, i18n and cli `patch`. ## PM assumptions, measured: three falsified, two confirmed 1. **Falsified: the props shape.** The suggested node was `{ type, plan }`. The measured node is `{ type, properties: { plan } }`. Cloud's pages are typed `Page` from `@objectstack/spec/ui`, and `PageComponentSchema` is a `strictObject` whose only props channel is `properties`, so a top-level `plan` is refused by the spec parse before objectui sees it. `cloud:onboarding-next` is authored the same way on the welcome page, and it reads `properties.*`. The widget reads `properties.plan` and nothing else, and the arm declares exactly that. 2. **Falsified: the registration spelling.** The precedent `register('cloud:onboarding-next', …, { namespace: 'app-shell' })` makes TWO registry keys, `app-shell:cloud:onboarding-next` and the fallback `cloud:onboarding-next`, and the namespaced ratchet counts both. Following it would have needed the arm to accept a second literal nobody authors. So the widget registers as `register('plan-status', …, { namespace: 'cloud', skipFallback: true })`: one key, one arm literal, and one generated line. 3. **Falsified: the i18n key name.** It is `cloudPlanStatus.current`, not `cloud.planStatus.current`, following the sibling blocks `cloudOnboarding.*` and `aiModelStatus.*`. 4. **Confirmed: the hook reads the plan, org-scoped.** `useEnvironmentEntitlements` sends `?organizationId=` from `useAuth().activeOrganization.id`, as the precedent does, and the endpoint resolves an absent parameter to the session's active organization. Its row-derived fallback carries no plan, so the widget marks a card only on `source: 'summary'`. 5. **Confirmed: `plan` values.** The endpoint's `plan` is cloud's `planKey(...)` over `PLAN_CODES` (`free`, `solo`, `team`, `business`, `starter`, `pro`, `enterprise`, `custom`). ObjectUI names none of them. ## Surface supplement: two files outside the claim's declared surface `packages/app-shell/src/index.ts` (one side-effect import line) and `packages/app-shell/package.json` (two `sideEffects` entries). A registration cannot ship without both. Without the barrel import, the console never evaluates the module. Without the array entries, `scripts/check-side-effects-array.mjs` goes red, because every registering module in the entry graph must be named there, and a bundler would be free to drop the registration. Both edits have the same shape as `CloudOnboardingNext`'s. **Overlap**, read on the open PR list (REST `pulls/N/files`, 2026-09-28): none of the ten non-release open PRs touches either file. The changesets release PR rewrites only the `version` field. **Seat:** please add these two paths to the claim's file surface. ## Verification, at `19fc41a` (the merge of `origin/main` `42687ba`) - **Suites** (`pnpm exec vitest run --maxWorkers=2`, through the verify lock): `packages/types/`, `packages/cli/`, `packages/i18n/`, plus a declared narrowing of `packages/app-shell/` (below). Result: `Test Files 407 passed (407)`, `Tests 8074 passed | 13 skipped`. - **The new pins, verbose**: `cloud-plan-status-arm-10919.test.ts` 12/12; `CloudPlanStatus.test.tsx` 10/10; the ratchet file 15/15, including the new `cloud:plan-status` row. - **type-check**, after `turbo run build --filter='@object-ui/app-shell^...'` (28/28): `@object-ui/types`, `@object-ui/i18n`, `@object-ui/cli` and `@object-ui/app-shell` all exit 0. `--listFilesOnly` shows each package's test program includes the new test files. - **eslint**: the 19 touched `.ts`/`.tsx` files, `--format json`, 19 files, 0 errors and 0 warnings. The narrowing is a measurement: the packages lint under the root `eslint.config.js` alone (no package-level config), and that config enables no type-aware linting (0 hits for `projectService`, `parserOptions`, `TypeChecked` or `tsconfigRootDir`), so this diff cannot move the verdict on any untouched file. - **Gates, all exit 0**: `regenerate-known-schema-types.mjs --check`, `check:side-effects-array`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys` (report), `check:registry-bare-names`, `check:doc-types`, `check:prompt-keys`, `check:unreferenced-sources`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:new-line-citations` (0 new), `check:control-bytes`, `check:handler-key-reads`, `check:changeset-claims` (report), `check:pending-changeset-literals`, `changeset:check`, `check-changeset-presence` (4 released packages, 1 changeset), `check-changeset-overwrite`, `check-type-check-coverage`, `check:phantom-deps`, `check:esm-specifiers`, `check-lint-coverage`, and `check-governed-queue-guard --test` over the 22 paths: NOT GOVERNED. - **Reverse verification**, run at `5c4a1d2` (the merge changed neither mutated file). Each leg was committed first, mutated through `ablation-replace.mjs` with an EXIT/INT/TERM restore trap, then restored with `git checkout HEAD --`: - **Ablate the arm** (drop the union line in `index.zod.ts`). The ratchet goes red: "refuses 398 registered namespaced key(s) at `type`; the pin is 397", and the Refused list names `cloud:plan-status`. The arm pins go red too (14 failed). Restored: blob `6fa23ee31c7c` equals HEAD, and `git diff HEAD` is empty. - **Ablate the match** (`entitlements.plan === plan` becomes `true`). The widget goes red on "renders nothing on a card whose plan is not the organization's" and "compares the plan code verbatim". Restored: blob `08d10662137c` equals HEAD, and `git diff HEAD` is empty. - Both subjects resolve to SOURCE (`vitest.config.mts` aliases `@object-ui/types/zod` to `src`), so no `dist` rebuild was part of either leg. **NOT MEASURED**, each with its reason: - `packages/app-shell/` full suite (856 files): at `--maxWorkers=2` it exceeds the foreground cap (exit 124 at 560 s, and exit 124 on shard 1/6 at 270 s). The run was narrowed to the 40 files that read what this diff touches: the two widget directories (`console/home/__tests__`, `environment/__tests__`), `src/__tests__/`, and the 13 tests that read `package.json`, `src/index.ts` or the barrel. Result: 40/40 files, 575 tests. CI runs the whole suite. - `check:sdui-registration-pins`, `check:eager-closure` and `check:eager-locale-catalogues` need a built console (PREREQUISITE NOT MET, exit 2). CI's `performance-budget.yml` runs them on `packages/**`. - `check:readme-exports` (exit 1) and `check:doc-examples` (exit 2) could not judge the READMEs of packages not built here (app-shell, cli, plugin-*). Among what was judged: 0 wrong-path and 0 fabricated. - No browser run against a live control plane. ## Acceptance notes (not filed; no carrier) - Each node reads the summary itself, so three cards make three GETs of the same summary. The hook has no shared cache, and none was added. - The Free card's CTA ("Get started") still shows to an organization on Free. This PR marks the card and leaves the CTA alone. Whether the widget should also stand in for the CTA is an open question in the report. - `useEnvironmentEntitlements`' docblock still describes an environment-list-only hook ("Only fetch when this is the environment list"), and it now has a second caller. Per the claim, the hook is not edited. - The precedent registrations' double-prefixed keys (`app-shell:cloud:onboarding-next`, `app-shell:cloud:ai-model-status` and their siblings) stay in the refused set. That family is objectui#10872's population. --- _Generated by [Claude Code](https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…rough a ref, not a memoised identity (objectui#11004) (objectstack-ai#11059) Fixes objectstack-ai#11004 Clause-②: no ## What changed `useDashboardAutoRefresh` (the one timer behind `DashboardGridLayout` and `DashboardRenderer`) armed its interval in an effect keyed on `[seconds, onRefresh, handleRefresh]`, and `handleRefresh` is a `useCallback` result. That is the shape AGENTS.md commandment objectstack-ai#10 bans (objectui#8640): if React discards the memo, the new function identity clears the interval and sets it again, and the phase restarts. The card's premise holds on `origin/main` at `30f912a0d`: the dependency list and its own warning comment were still there. - The latest host handler now lives in one ref (`onRefreshRef`), updated in a dependency-free effect. This is the house shape, the same one used by `surfaceDeepLinkChannel.ts` and the studio-design panels. - A module-scope `runRefresh(handler, setRefreshing)` holds the run: call the handler, then show the 600ms indicator. The manual `handleRefresh` (a `useCallback` with an empty list, kept only for cost) and the interval callback both call it, and both read the handler through the ref. - The interval effect is keyed on the two VALUES it reads: `[seconds, wired]`, where `wired = Boolean(onRefresh)`. Zone 2, item 2, was decided from commandment objectstack-ai#10: `onRefresh` leaves the list too. It is a caller-supplied identity, which is the cross-boundary instance of the same class the commandment names ("a prop, a hook return, a context"). The ref still meets the handler-swap case, because the CURRENT handler is the one called. The one behaviour change is the intended one: when the host passes a new handler identity at the same period, the phase is kept instead of restarted. No in-tree host passes a changing `onRefresh` today. `DashboardWithConfig` only passes its prop through, and the console's `DashboardView` wires none. ## The new case The new case is `keeps its phase when a handler identity changes at an equal period (objectui#11004)`, inside the existing per-surface `describe.each`, so it runs once per surface. It runs as two steps on one 30s schedule: 1. At 20s it forces a memo discard, with the same host handler and the same period. The discard uses a module-level `react` proxy, the same technique as `providerCtxIdentity.discarded.test.tsx` in `packages/permissions`, and is inert unless a case arms it. It then asserts the run still lands at 30s. A control asserts that the discard really reached the hook: `handleRefresh` comes back as a different function. Without that control, a proxy that patched nothing would read green. 2. At 50s the host swaps in a new handler at the same period. The case asserts the NEW handler runs at 60s and the old one does not run again. All 22 earlier cases are untouched and green, the equal-period phase case and the handler-swap case among them. ## Evidence, all at HEAD `8fa76e0ed` - `pnpm exec vitest run packages/plugin-dashboard/src/__tests__/dashboardAutoRefreshTimer.test.tsx`: base tree `Tests 22 passed (22)`, this branch `Tests 24 passed (24)`. - Reverse verification (fix committed first, hook reverted to `30f912a0d`, restored with `git checkout HEAD`, blob equal to HEAD and `git diff HEAD` empty): `Tests 2 failed | 22 passed (24)`. The two failures are the new case, one per surface, at `a discarded memo re-armed the interval`. The controls passed, so the discard did reach the hook. - Mutation legs, through `ablation-replace.mjs` (anchor hit 1 to 0, blob changed, then restored to the HEAD blob with `git diff HEAD` empty): - `onRefresh` added back to the interval list: `2 failed | 22 passed (24)` at `a new host handler identity re-armed the interval`. Step 1 passed, so step 2 is load-bearing on its own. - `handleRefresh` added back (even as a ref-reading, empty-list callback): `2 failed | 22 passed (24)` at `a discarded memo re-armed the interval`. - `pnpm exec vitest run packages/plugin-dashboard/`: `Test Files 151 passed (151)`, `Tests 1379 passed | 6 skipped (1385)`. - `pnpm --filter @object-ui/plugin-dashboard type-check`: exit 0, after building the package's dependency closure (`--filter '@object-ui/plugin-dashboard^...' run build`, exit 0). `tsc -p tsconfig.test.json --listFiles` includes the changed test file. - `pnpm exec eslint --format json` on the two changed source files: 2 files, 0 errors, 0 warnings. `react-hooks/exhaustive-deps` accepts `[seconds, wired]`. This is a declared narrowing: the config enables no type-aware linting (no `parserOptions.project` or `projectService`), and no local rule in `eslint-rules/` reads another file, so this diff cannot move a verdict on an untouched file. The package-wide `eslint .` belongs to CI. - `node scripts/check-*.mjs`, each exit 0: changeset-presence, control-bytes, vi-mock-specifiers, vi-mock-inherit, vi-mock-override-shape, new-cross-file-line-citations (`0 new citation(s)`), changeset-no-major, changeset-claims, pending-changeset-literals, test-path-roots. - Nothing in `scripts/`, `eslint-rules/` or `.github/` enforces commandment objectstack-ai#10. A grep for it returned zero hits, with a positive control that did hit, which matches the commandment's own "Nothing enforces this rule". Changeset: `.changeset/11004-autorefresh-handler-ref.md`, `'@object-ui/plugin-dashboard': patch`. ## Acceptance notes - Observation, not fixed here: the console's `DashboardView` mounts `DashboardRenderer` with no `onRefresh`, so an authored `refreshIntervalSeconds` never starts a timer in the console. The Studio metadata form still offers the field (its zh label is "自动刷新"). This was read from the JSX, not measured in a running console. It is handed to the seat in the report. --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ Co-authored-by: Claude <noreply@anthropic.com>
…le a save is in flight — one sent-snapshot check in useDraftAutoSave for every caller (objectui#11204) (objectstack-ai#11230) Fixes objectstack-ai#11204 Clause-②: no ## What changes Studio's shared `useDraftAutoSave` (in `StudioDesignSurface.tsx`) now owns the one check that every caller's post-await dirty clear goes through. It hands each save it sends a `DraftSend` claim on the snapshot that save sent. A save that lands runs its `set*Dirty(false)` only while `sent.unmoved()` is true, which means the buffer, as last committed, still serialises to what was sent. If an edit is taken while the save is in flight, the buffer stays dirty. The autosave is unblocked when the save ends, and it sends that edit next. - The hook now returns `{ flush, sending }`. Both come from a state initializer, so their identity is stable (AGENTS.md objectstack-ai#10). `flush` behaves exactly as objectui#11189 left it. `sending(body)` gives a claim to a save that the caller sends itself, not through the timer. - The hook's committed-snapshot ref (`pendingRef`, also read by `flush`) is now written in a layout effect. It is therefore current as soon as a render commits, so a save that lands right after an edit reads that edit. - `useDraftAutoSave` is module-private. No schema, prop, type or accepted set moves. ## Measured before the fix: base `c3df43a42`, every caller The mount is happy-dom, with the real pillars and the real registered `FlowPreview` / `FlowInspector` and `PagePreview` / `PageBlockInspector` / `PageDefaultInspector`, as in the objectui#11124 and objectui#11136 harnesses. The Data pillar's records grid is a double that hands the pillar a column order through the grid's own authoring context. A server double holds the first save in flight. Each row is read 3.5 s after that save lands. The pins below are these same cases. They are red on the base, and the red diffs are these readings. | caller | first edit (sent, held) | edit during the flight | saves received | on screen | |---|---|---|---|---| | Automations `doSave` (the control) | start label 'Kick-off' | 'Kick-off two' | 1, carrying 'Kick-off' | 'Kick-off two' | | Data `doSave` (autosave) | Add field, field_3 | Add field, field_4 | 1, carrying title, status, field_3 | '4 fields' | | Data `doReorderFields` (a save the pillar sends itself) | column reorder to status, title | Add field, field_3 | 1, carrying status, title | '3 fields' | | Interfaces page inspector `doSave` | page label 'Welcome' | 'Welcome two' | 1, carrying 'Welcome' | 'Welcome two' | | Interfaces nav `doNavSave` (with `navGen`) | Add nav item | Add nav item, then remove it again | 1, carrying Home menu, Landing menu, New item | the same one item added. The pillar still reports dirty; 2 s after "Done", editing is still open, still dirty, and nothing more was sent | All five reproduce. The Automations row reproduces objectui#11189's reading, so it is the control. Data and the page inspector reproduce too; they had been read at source only. The nav row is the case the per-pillar generation got wrong: the buffer equals what landed, yet it stays dirty for good, and the leave guard, the copilot refresh hold and "Done" all stay stuck until another edit is made. ## Every post-await dirty clear, and where each one goes now - Interfaces page inspector `doSave` becomes `if (sent.unmoved()) setIfDirty(false)`. - Interfaces nav `doNavSave` becomes `if (sent.unmoved()) setNavDirty(false)`, replacing the `navGen` comparison. - DataPillar `doSave` becomes `if (sent.unmoved()) setDirty(false)`. - DataPillar `doReorderFields` is outside the hook's timer, but its clear goes through the same check, with a claim from `sending(body)`. - AutomationsPillar `doSave` becomes `if (sent.unmoved()) setAutoDirty(false)`. The unconditional clears that remain are buffer replacements, not save completions: each pillar's load effect, and the nav read-only put-back. The objectui#11167 effect's text is unchanged. ## `navGen`: folded into the hook The nav autosave now reads the same claim. `navGen`, its bump in `onNavPatch` and the committed generation are gone. `navCommittedRef` keeps only `dirty`, which the app load's same-package install guard still reads, unchanged. The nav autosave can use the hook's mechanism because every nav edit changes `appDraft`, the snapshot the hook already serialises, and `doNavSave` sends a cleaned copy of exactly that snapshot. The claim compares content, not an edit count, which is the last table row: a generation cannot tell an edit that was undone during the flight from one that was not. PR objectui#11202's pins still assert the same behaviour and are green unmodified: "Done" flushes, an Add during a held save lands in the next save, and the leave guard holds. ## For objectui#11203, which rides after this card A nav save that lands after a package switch has installed the other package's app now reads the buffer as moved, and leaves `navDirty` set. `navGen` cleared it, because a load's install never bumped the generation. So the switch-during-a-save-in-flight interleaving now also reaches that card's spurious save on the switched-to package. Before, only the ordinary case (an unsaved edit, no save in flight) did. That card's reset gets simpler: there is no generation left to keep in step. A reset of `navDirty` and nav editing at the switch closes this interleaving too, because the landing save's claim reads moved and leaves the reset alone. ## Tests New: `StudioDesignSurface.autosaveInFlight-11204.test.tsx`, with 6 cases: - one in-flight pin per caller: Automations; Data autosave; Data column reorder; page inspector; - a Data control (a reorder with nothing edited meanwhile is the only save); - the nav undo case. ## Ablation, at `201233e1a` Each leg went through objectstack's `scripts/ablation-replace.mjs` in WRAP mode, which verifies the anchor count, the blob change, the restore blob against HEAD, and that `git diff HEAD` is empty afterwards. The subject is the source, imported by relative path, so no `dist` is involved. The prediction for every leg was red on the named pins. - Leg A removes the check: the claim always reads unmoved. Result: 6 failed / 6 passed. Red are the four in-flight pins here and the two in-flight pins of the objectui#11189 suite. Green are the Data control, the nav undo case (unmoved is the right answer there) and the four other objectui#11189 pins. - Leg B restores Automations' own unconditional clear. Result: 1 failed / 11 passed, with only the Automations pin red. - The first attempt at leg B was refused by the tool before anything ran, because its replacement text was a substring of the anchor. It was re-run with a distinct replacement. - Both legs are restored; the blob equals HEAD. ## Gates, at `201233e1a` - Closure build (`turbo run build --filter=@object-ui/app-shell^...`): 28/28, exit 0. - `pnpm --filter @object-ui/app-shell type-check`: exit 0. `tsconfig.test.json --listFilesOnly` lists the new test. - `vitest run packages/app-shell/src/views/studio-design/`, in 3 chunks: 77 files / 437 tests passed. - The root `scripts/__tests__/` suite, because a changeset is markdown: 177 files passed, 2 skipped; 5371 tests passed, 2 skipped. - eslint on the 2 changed sources: 0 errors. `StudioDesignSurface.tsx` keeps its 18 warnings, the same rules and messages as the base copy apart from line numbers. - Exit 0 on each of: `check-changeset-presence`, `check:control-bytes`, `check:new-line-citations` (0 new), `check:changeset-claims`, `check:pending-changeset-literals`, `changeset:check` and `markdown-test-inputs --audit`. Declared narrowing: the whole-repo `pnpm test`, `pnpm lint` and other packages' type-checks run in CI. Only suites under `studio-design/` mount these pillars; the console `StudioRoute` tests stub the surface. ## Acceptance notes - The dev report on this card carries a measured, separate defect for the seat to route (not filed here). The Automations pillar and the Interfaces page inspector send one leaf's edited document as another leaf's draft when the author opens another flow or page inside the autosave's debounce while that leaf's load is slow. This PR does not touch that path. - The Automations load clears `autoDirty` in its `finally`, which also runs when the load fails. Read, not measured; not filed. - The layout-effect write of `pendingRef` is not pinned. The harness's edits are discrete events, which also flush passive effects in the same commit. - The pending changeset `6681-declared-lazy-marketplace-routes.md` names this file only as an importer of `SuggestedBindingsPanel`. That stays true. The author is the `domain:ui` seat 2 dev agent, session `https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec`, branch `claude/issue-11204-draft-autosave-generation`. --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ail closed while the permissions payload has not loaded (objectui#11212) (objectstack-ai#11237) Fixes objectstack-ai#11212 Clause-②: yes Rider 1 of objectui#4421 on the legs that still answered SHOWN or ENABLED while the permissions payload had not loaded. The ruling, verbatim: "permission-shaped bindings are **fail-closed while the permissions payload has not loaded** — the opposite of the predicate default — or the first-paint leak reappears. Pin this." ## What changed Measured through the real `MePermissionsProvider` → `ExpressionProvider` → `ActionProvider` → real renderers, one verb (`delete`), three states. Reproduced red on `origin/main` (`dded788ada`) before any source edit. | leg | before: not loaded / granted / denied | after | |:--|:--|:--| | `action:group` inline member `visible` | SHOWN / shown / hidden | hidden / shown / hidden | | `action:group` dropdown member `visible` | SHOWN / shown / hidden | hidden / shown / hidden | | `action:group` host `visible` | SHOWN / shown / hidden | hidden / shown / hidden | | `action:icon` `visible` | SHOWN / shown / hidden | hidden / shown / hidden | | related-list toolbar (`RelatedToolbarButton`) `visible` | SHOWN / shown / hidden | hidden / shown / hidden | | `record:quick_actions` `visible` (ActionRunner bag) | hidden / HIDDEN / hidden, faulting in all three | hidden / shown / hidden | | `page:header` `disabled: !current_user.can(...)` | ENABLED / enabled / disabled | DISABLED / enabled / disabled | - **`action:group` (`@object-ui/components`)**: both leaves read one same-file hook, `useMemberVisible`, which evaluates `visible` with `throwOnError`, as `action:menu`'s `useMenuActionVisible` does. The group's own `visible` takes the same option. A predicate that faults hides the action and is reported once, naming it. - **`action:icon` (`@object-ui/components`)**: `visible` gets `throwOnError`, as on `action:button`. The auto-trigger follows the same verdict, and its comment now names the fail-closed policy. - **`RelatedToolbarButton` (`@object-ui/plugin-detail`)**: `visible` gets `throwOnError`. The header comment on `permittedToolbarActions` already called this CEL "fail-CLOSED"; before this change that comment was wrong, and now it is right. - **`page:header` `disabled` (`@object-ui/components`, `containers.tsx`)**: `evalHeaderPredicate` takes the fault fallback per key. `visible` and `hidden` stay `false`. `disabled` is now `true`, so a `disabled` predicate that faults renders the button disabled. - **`useActionEngine` (`@object-ui/react`)**: the hook binds the predicate scope's subject (`usePredicateScope().current_user`) as `current_user` on the runner bag that `getActionsForLocation` filters against. It is the same object `ExpressionProvider` publishes, not a copy, because the engine answers `can` only for a receiver identical to the bound `current_user`. It is bound on the standalone runner and on a shared provider runner, with or without per-render keys. The memo is keyed on the subject's serialisable fields and on its permissions map, not on the subject's identity (AGENTS.md objectstack-ai#10). The runner's `user` / `ctx.user` / `os.user` stay the host's object, because that object carries the `systemPermissions` the runner's capability gate reads. The policy is per key, not a `can()` special case. A `visible` that faults on an unbound root (`nope.deep == 1`) is hidden on every leg above, and a header `disabled` that faults is disabled. The pin checks both. ## The `disabled` decision and its blast radius Rider 1 says the gate is closed while the payload has not loaded. For a `disabled` gate, closed means DISABLED. Measured facts behind the decision: - The record header's `disabled` fail direction is not decided in `evalRowPredicate` (`listConditional.ts`). That function returns whatever `fallback` its caller passes. The header's `evalHeaderPredicate` passed `false` for all three keys, and on `disabled` that value means ENABLED. The fix is in the caller, and `listConditional.ts` is untouched. - Only `page:header`'s `resolveDisabled` goes through that leg: the inline buttons and the `⋯` overflow items of authored header actions. Built-in `sys_edit` / `sys_delete` arrive with a boolean `disabled` that `RecordDetailView` computes itself, so this change does not touch them. - Blast radius: every authored `page:header` action whose `disabled` predicate faults, whatever the cause. That includes a misspelled field, a retired `data.*` or bare spelling, an unbound root, and `can()` before load. All of these now render disabled instead of enabled. This matches what the other surfaces already did. `action:button`, `action:menu`, `action:group`, `action:icon` and `record:quick_actions` evaluate `disabled` fail-soft to `true`, which on this key is DISABLED. `ActionRunner.execute` refuses a faulting `disabled` with "Action is disabled". So on the header, a faulting `disabled` rendered an enabled button that the runner then refused. - Not widened here: the row menu item's `disabled` (`RowActionMenu`, measured ENABLED / enabled / disabled for `disabled: !current_user.can(...)`), the data-table row action's `disabled`, and the built-in `disabledWhen` predicates (documented fail-soft since PR objectstack-ai#4515). These are other packages or other keys, so they are reported as a fork in the dev report, not changed here. ## Mechanism notes (PM assumptions measured) - The card's table still held on `dded788ada` after PR objectstack-ai#11221: red first, 14 of 28 arms. - `throwOnError` hides and warns once per (label, predicate), as assumed. `action:group` got a small same-file hook instead of two copies of the option, which mirrors `action:menu`. - **Dashboard header actions have no `visible` leg.** `DashboardRenderer` registers defs that carry only `name` / `type` / `target` / `label`, and `@objectstack/spec`'s dashboard header action is a strict object of `label` / `actionUrl` / `actionType` / `icon`. So `current_user.can(...)` cannot be authored there, and `record:quick_actions` is the only `visible` surface on the ActionRunner bag. The binding still reaches the dashboard's runner, where it gates nothing today. - `ActionRunner.ts` is untouched. The binding happens where React can read the predicate scope, in `useActionEngine`. ## Reach On the console's app routes the not-loaded state does not render, because `MePermissionsProvider` holds a loading screen. `/forms/:name` is a sibling route in `App.tsx`, outside that provider. A throwaway probe (not committed) rendered the real `App` at `/forms/showcase_task.edit`, with `FormPage` replaced by an `action:icon` gated on `current_user.can('account', 'delete')` and an ungated companion. It read `isLoaded=false subjectCarriesPermissions=false gatedIcon=hidden companion=shown` at this branch's head. With the `action:icon` fix ablated, the same probe read `gatedIcon=SHOWN`. ## Tests - New pin `packages/app-shell/src/providers/__tests__/currentUserCan-failClosed-11212.render.test.tsx`: 7 legs × 3 states, plus 7 per-key unbound-root arms, 28 tests. Every arm has an ungated companion and its own action name, because the fault reports are warn-once per locator. - Pins that recorded the old fail-soft answers are flipped: - `action-record-predicate-root.test.tsx`: the `action:icon` and `action:group` `visible` fault and retired-spelling cases, which now read hidden. - `page-header-predicate-dialect.test.tsx`: a faulting `disabled` now reads DISABLED, reported once. - `action-template-predicate-gate.test.tsx`: only the `failClosed` site flags and labels changed; the assertions did not. - `related-toolbar-visible.test.tsx`: new faulting-predicate case. - Reverse verification, at `8d44406f79` with the fix committed first. Mutations went through `ablation-replace.mjs`: `throwOnError: true` → `false` in `action-group.tsx` (×2), `action-icon.tsx` and `RelatedList.tsx`; the header `disabled` fallback `true` → `false`; `useActionEngine` binding nothing. Predicted before the run: 25 red. Result: `Tests 25 failed | 123 passed (148)`. The 25 red were: - 14 in the new pin: the NOT LOADED and unbound arms of the six policy legs, plus `record:quick_actions` GRANTED, and DENIED through its silence assertion. - 9 in `action-record-predicate-root`. - 1 in `page-header-predicate-dialect`. - 1 in `related-toolbar-visible`. - Every file restored to its HEAD blob, and `git diff HEAD` was empty. - Head `b8d647a3c4`. The union ran after the last commit on a clean tree. Heavy runs went through the shared verify lock, and the verdicts quoted are the tools' own lines: - `pnpm exec vitest run packages/components/ packages/react/`: `Test Files 447 passed | 1 skipped (448)`, `Tests 4758 passed | 24 skipped`, exit 0. - `pnpm exec vitest run packages/plugin-detail/ packages/app-shell/src/providers/__tests__/ packages/core/src/actions/ packages/core/src/evaluator/` plus the objectui#4421 permissions pin, plus 98 consumer test files outside those trees: `Test Files 384 passed | 1 skipped (385)`, `Tests 6177 passed | 35 skipped`, exit 0. The consumer files are every test outside components / react / plugin-detail that names `page:header`, `action:group`, `action:icon`, `RelatedList`, `quick_actions` or `useActionEngine`. - Build: `turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`, `Tasks: 28 successful, 28 total`. - `type-check` for `@object-ui/components`, `@object-ui/react`, `@object-ui/plugin-detail` and `@object-ui/app-shell` exited 0 on all four. `tsc -p tsconfig.test.json --listFilesOnly` confirms the five edited or new test files are in those programs. - Gates: - `node scripts/check-changeset-presence.mjs` exit 0 (11 source files of 4 released packages, 1 changeset). - `pnpm check:control-bytes` exit 0. - `pnpm check:action-forward-parity` exit 0. - `pnpm check:new-line-citations` exit 0, 0 new citations. - `pnpm check:doc-types` exit 0. - `pnpm check:changeset-claims` exit 0 (report-only). It named 7 pending changesets that cite the touched files; I read each paragraph, and none describes a fault policy. - Lint: CI's per-package `eslint .` over the 11 touched files. Errors are 0. Warnings per file equal the base counts; the one exception is the new pin, which has 2. ## Acceptance notes (not filed) - `RelatedToolbarButton` gates on truthiness (`visiblePred && !isVisible`), so a toolbar action authored `visible: false` renders. A probe measured SHOWN. `RelatedRecordActionsBridge`'s `deriveActions` passes `visible` through unfiltered. This is the objectui#3812 declared-gate class, not this card's fault-policy class, so it is left untouched. Carrier: none. - `ActionRunner.execute`'s `disabled` gate comment says a fault "defaults to NOT-disabled". The code blocks, which is the direction this change rules for `disabled`. So only the comment is wrong. It was recorded as a neighbour on PR objectstack-ai#11208, and it is untouched here. - The runner bag gains `current_user` only through `useActionEngine`. Because that hook merges into a shared provider runner, a page with a `record:quick_actions` block also binds `current_user` for the provider's `execute` gates, the same way it already binds `record` / `recordId` / `objectName`. The ActionProvider-level binding stays open under the PR objectstack-ai#11208 neighbour note. --- _Generated by [Claude Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ites the previous item's buffer into the one just opened (objectui#11232) (objectstack-ai#11265) Fixes objectstack-ai#11232 Clause-②: no objectui#11203 (the package-switch member, closed by triage as folded into this card) rides here with its three pins. Implemented on `claude/issue-11232-autosave-send-bound-to-target`, dispatched by the `domain:ui` seat 1 PM loop, session `https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ`. Triage `5915804318` is the ruling executed; claim `5918635427`. ## What changed | where | before | now | |:--|:--|:--| | `useDraftAutoSave` | the timer was keyed on the dirty flag, the blocked flag and the snapshot; the save it called was read through a ref that already addressed the newly opened item, while the buffer still held the previous item's document | takes `target`, the item its `save` addresses (a primitive `type:name`, AGENTS.md objectstack-ai#10). A dirty period is bound to the item it began on. A period that began on another item is never sent, by the timer or by `flush`, and it ends when the caller's dirty flag falls (each caller's load clears it as it installs the new buffer). A save's claim (objectui#11204) reads moved once the target has changed | | the four callers | none | pass `flow:NAME` (Automations), the page leaf key (Interfaces page inspector), `object:NAME` (Data), `app:NAME` (Interfaces nav) | | where `StudioDesignSurface` renders `InterfacesPillar` | not keyed: a package switch kept the route mounted, and with it the nav editor's dirty flag, its open editing and the open page | `key={packageId}`: one reset of the whole pillar on a package switch (triage's preferred shape for objectui#11203) | No other line of `DataPillar` changes: its hook call gains the required `target`, and its switch (the control) is untouched. ## The pending edit is dropped, not sent to its own item A switch inside the debounce drops the previous item's unsent edit. It is never written to the newly opened item, and it is not re-targeted to its own item either. Why this side of the ruling's choice: - It is what already happens on every pillar whenever the new item loads inside the debounce: the load installs the new buffer and clears the dirty flag, which cancels the timer. The defect was only the slow-load path. Dropping adds no new write; re-targeting would add a save nobody sends today. - It is what the Data pillar's switch has always done (its synchronous clear), the card's named control. - It matches the confirmed discard of a package switch (objectui#11203), where the author said "discard". - PR objectui#11230's sent-snapshot claim: a save that lands after a switch reads moved, so it never clears the flag of the item opened since. objectui#11189's nav-flush rule ("Done" sends a pending edit at once) is a close of the SAME item's editor and is unchanged; `flush` now also refuses a period that began on another item. ## Which fix covers which member (the claim asked) - Automations flow switch, page-inspector page switch: the hook (ablation leg A). - A save in flight across a page switch: the hook's claim binding (leg C). The save itself always addressed its own item (the save call captured it). - Data object switch: the control, green at base and at HEAD. - The nav autosave on a package switch (objectui#11203): the key (leg B). The hook alone cannot reach it: the nav editor's dirty flag and open editing are the pillar's state, so after a confirmed discard a flag still stood over the next package's app, and no edit after the switch could start a period the hook would send. The page inspector's package-switch state, which triage asked measured in the same pass, is the key's too. ## Premises re-measured at `origin/main` `02a22957c0` (after PR objectui#11259) The objectui#11204 harness reading still holds on the tip. Pin file first, fix absent (the red run): - Automations: edit flow `notify_owner`'s start label to 'Kick-off', open `nightly_digest` (load held) inside the debounce: **1 save, of `nightly_digest`, carrying `notify_owner`'s document** (label 'Notify owner', start 'Kick-off'). - Page inspector: edit page `home`'s label to 'Welcome', open `landing` (load held): **1 save, of `landing`, carrying `home`'s document** (label 'Welcome', the hello block). - Package switch, confirmed discard, fast load: **1 save of app `beta_app` to package `com.beta.app`**, B's own navigation, unedited (objectui#11203's reading). With B's app load held: **1 save of `acme_app` to `com.acme.app`**, the edit the author had just confirmed discarding. - Page inspector on a package switch: page `home` of package A stays open on package B, and an edit then saves page `home` with package `com.beta.app`. - Data (the control) and the cancelled discard: green. PR objectui#11259 did not close any member: its edits (the load effect's first-leaf pick, the `?surface=` restore, `StudioNavItemInspector`) do not touch the hook or the switch paths. ## Pins New file `StudioDesignSurface.autosaveSwitch-11232.test.tsx`: the real pillars and the real registered `FlowPreview` / `FlowInspector` / `PagePreview` / `PageBlockInspector` / `PageDefaultInspector`, a server double that records every save with its package and can hold an item's load or a save. - Automations: a flow switch inside the debounce sends nothing; the edit after the new flow's load saves to it (the control). - Page inspector: the same for a page switch. - Page inspector: a save in flight across a page switch lands on its own page, and an edit typed on the previous page's buffer before the next page's load lands is not sent there. - Data: an object switch inside the debounce sends nothing (the control, as it always has), and an edit after the load saves to it. - objectui#11203, through the real surface and its `PackageSwitcher`: a confirmed discard sends nothing to the new package and closes editing; an edit after the switch saves normally, to `beta_app` in `com.beta.app`; the same with B's app load held; a cancelled discard keeps package A and its edit, which then saves to `acme_app` in `com.acme.app`. - Page inspector on a package switch: the new package's page opens and an edit saves to it, in the new package. The objectui#11189 / objectui#11204 / objectui#11167 / objectui#11196 autosave pins are unmodified and green (see the directory run below). ## Reverse verification and ablations (each on a committed HEAD; mutation and restore proven on disk) Every leg ran `pnpm exec vitest run` on the pin file (8 cases) at HEAD `3cd7a994d1`. The pins import the pillar source directly (a relative import, no `dist/`), so no build leg applies. Legs A to C went through objectstack's `scripts/ablation-replace.mjs` in WRAP mode (anchor x1 to x0, blob moved, restore proven: blob == HEAD `f8072729aaf6` and `git diff HEAD` empty). The base leg swapped in the base file under a trap, with the same two restore proofs. | leg | mutation | predicted | observed | |:--|:--|:--|:--| | base | `StudioDesignSurface.tsx` at `02a22957c0` (blob `a2259cfc6561`, verified on disk) | red except the two controls | 6 failed / 2 passed: the Data control and the cancelled discard green. The in-flight pin reads 2 saves, `home` 'Welcome' then `landing` 'Typed during load'; the package-switch page pin finds `home`'s hello block still open on package B | | A | the timer's `owned()` check made a no-op | red: Automations, page switch, in-flight | 3 failed / 5 passed, exactly those | | B | `key={packageId}` deleted | red: the confirmed-discard pin (editing stays open over a standing flag), the package-switch page pin | 3 failed / 5 passed: those two AND the slow-load discard pin, which read 1 save of `acme_app` to `com.acme.app` (the discarded edit). Direction differs from the prediction: an instrumented re-run read `target` still `app:acme_app` when the debounce fired, because the new package's app name had not committed yet (see the harness note under Acceptance notes). So the key, not the hook, holds that pin | | C | the claim's target comparison removed | red: the in-flight pin only | 1 failed / 7 passed, exactly that | ## Tests and gates at `3cd7a994d1` Every run below is at `3cd7a994d1`, from the worktree root, with root-relative paths. Pass counts are read from vitest's own summary lines. - Build closure: `turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`, 28 of 28 tasks, exit 0 (under the shared verify lock, run at `e4594fdc66`, whose source is byte-identical to HEAD's; only the pin file moved since). - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `tsc -p tsconfig.test.json --listFilesOnly` lists the new pin file once. - The pin file: 8 of 8 passed. - `packages/app-shell/src/views/studio-design/`, every test file (82, the tree listing and `git ls-files` agree), in three chunks under the lock: 29 files / 137 tests, 25 / 131, 28 / 207, so 82 files / 475 tests passed, 0 failed. It holds every suite that names `useDraftAutoSave`, `AutomationsPillar`, `InterfacesPillar` or `PackageSwitcher` (`git grep` finds none outside it), and the objectui#11189 / objectstack-ai#11204 / objectstack-ai#11167 / objectstack-ai#11196 autosave pins unmodified. - The surface's other renderers: `StudioRoute.test.tsx`, `StudioRoute.landingI18n.test.tsx`, `App.uploadAltitude-10131.test.tsx` and `studio-locale.i18n.test.tsx`: 4 files / 30 tests passed. - eslint on the 2 changed source and test files: 0 errors. `StudioDesignSurface.tsx` has 18 warnings, the same rules and counts as the base version (linted as `git show BASE:path` through `--stdin`). The pin file has none. No type-aware lint is configured, so the diff cannot move another file's verdict. - Gates, each exit 0: `check-changeset-presence`, `check:control-bytes`, `check:new-line-citations` (0 new), `check:changeset-claims` (the one pending changeset naming this file, `6681-declared-lazy-marketplace-routes.md`, was read: it says this file statically imports `SuggestedBindingsPanel`, and that still holds), `check:pending-changeset-literals`, `changeset:check`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:metadata-write-doors`, and `check-governed-queue-guard --test` (NOT GOVERNED, 3 paths). - NOT MEASURED: CI (the full farm, `pnpm lint`, e2e) and a browser run. ## Acceptance notes - **The dropped edit, on every switch.** An edit made less than 1.5 s before any switch (flow, page, object or package) is dropped, as above. On a package switch this now includes a page-inspector edit: before, it happened to be sent to its own page in its own package (the page save's callback still held the previous package); now the remount drops it. Page edits are not held by the surface's leave guard (only nav edits are), so no prompt announces it. Read at source, not a new class: the pillar-tab switch unmounts the pillar and drops such an edit the same way today. - **An in-flight save's own UI effects are not bound to its item.** A save of the previous item that lands after a switch still sets the "unpublished draft" badge, and a failure its error, on the item opened since. Nothing is written; read at source, not measured. - **Harness boundary.** `pastDebounce` waits inside one `act()` scope, which holds React renders from async continuations until the scope ends while real timers fire. So in leg B the switched-to app's name (set after the app list's await) was not yet committed when the debounce fired, and the hook still read the previous app as the target; that interleaving is also the real one when the new package's app list is slow, and the key is what holds it. Not measured in a browser. - Out of scope, measured on this branch and reported to the seat, not fixed here: the previous item's buffer stays on screen and editable under the newly opened item while its load is in flight, and a save from there writes it into the new item (page inspector autosave, the Automations enable toggle, the Data pillar autosave); and a package switch keeps the Automations pillar's open flow of the previous package, which an edit then saves into the new package. --- _Generated by [Claude Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… disabled gates fail closed while permissions load, and ActionProvider binds current_user on its runner (objectui#11242) (objectstack-ai#11268) Fixes objectstack-ai#11242 Clause-②: yes (widening) Rider 1 of objectui#4421 on the last two authored-action `disabled` legs, and the "one bag" rule (objectui#6493) for the shared action runner. The ruling, verbatim: "permission-shaped bindings are **fail-closed while the permissions payload has not loaded** — the opposite of the predicate default — or the first-paint leak reappears. Pin this." For a `disabled` gate, closed means DISABLED. ## What changed Measured through the real `MePermissionsProvider`, `ExpressionProvider`, `ActionProvider` and the production renderers, one verb (`delete`), three states. The "before" column was measured at the base `615346d61` with the new pin copied into an unmodified tree, before any source edit: exactly the 6 arms marked in capitals were red there. | leg | before: not loaded / granted / denied | after | |:--|:--|:--| | row menu item (`RowActionMenu`), `disabled: !current_user.can('account', 'delete')` | ENABLED / enabled / disabled | DISABLED / enabled / disabled | | data-table row action (`data-table`, a related list's rows), same gate | ENABLED / enabled / disabled | DISABLED / enabled / disabled | | built-in Delete `disabledWhen`, both menus (the control) | enabled / enabled / disabled | unchanged | | runner `disabled` re-check through `useAction()`, no action-engine block on the page | refused / REFUSED / refused | refused / runs / refused | | `page:header` click, granted, no action-engine block on the page | button enabled, runner REFUSED | button enabled, runner runs | - **Row menu item (`@object-ui/plugin-grid`) and data-table row action (`@object-ui/components`).** The same mechanism PR objectui#11237 gave `page:header`'s `disabled` leg: the caller passes its key's own fail direction (`fallback: true`) to the row-predicate entry, and the policy is per key, not a `can()` special case. That entry's fallback also answers an ABSENT predicate, so the verdict counts only where a gate is declared, read through core's one definition of that question (`hasDeclaredPredicate`). An absent, empty or blank `disabled` stays not disabled (pinned). - **Scope question (not-loaded only, or every fault?).** Every fault, as PR objectui#11237 did for `page:header`: a `disabled` that faults on an unbound root (`nope.deep == 1`) is DISABLED on both legs (pinned). - **Built-in `disabledWhen` (`RowActionMenu`, `data-table`) is untouched** and stays fail-soft, as documented and pinned since PR objectui#4515 (option B was not taken). The `BuiltinRowActionPredicates` docblock in `RowActionMenu.tsx` said that posture renders the item disabled; it leaves it enabled (the spec's own wording, and the existing `RowActionMenu.test.tsx` pin). The docblock is corrected in the file this PR already edits, and now says where the two families part. - **`ActionProvider` (`@object-ui/react`)** binds the predicate scope's subject (`usePredicateScope().current_user`) as `current_user` on its runner: the same object, not a copy, bound during render and keyed on the subject's serialisable fields and its permissions map, never on its identity (AGENTS.md objectstack-ai#10). The runner's `user` / `ctx.user` / `os.user` stay the host's object. So every `execute` gate under a provider mounted inside the scope answers `current_user`, whatever else the page mounts. - **`useActionEngine`'s PR objectui#11237 write is KEPT, because it is measured still needed** (ablation legs A4 and A5 below). Under a provider mounted inside the scope it only re-binds the object the provider already bound (A4: 0 red). It is the only writer for two runners: the hook's own standalone runner (A5: the standalone arm goes red), and a shared runner whose provider sits ABOVE the scope (probe P1: `record:quick_actions` GRANTED reads HIDDEN without it). The console's global provider is that shape: `GlobalActionRuntimeProvider` wraps the routes and `ExpressionProvider` is mounted inside them. Only its comment changed. - **`ActionRunner.execute`'s `disabled`-gate comment (`@object-ui/core`), comment only.** It said a fault "defaults to NOT-disabled". `evaluateCondition` handles its own faults and answers its fail-soft `true`, which on this key refuses the action; the `catch` is reached only by a throw from outside that handling. The code is right. - **Docs (`content/docs/layout/page-header.mdx`).** The row menu's items now take the same `disabled` direction as the header, in a grid and in a related list's table, and the built-in `disabledWhen` is named as the exception. The page also states that on the runner's own gates `current_user.can(…)` answers while `user.can(…)`, `ctx.user.can(…)` and `os.user.can(…)` fault (probe P3), as the review of PR objectui#11237 noted. No `user` binding is added. ## Mechanism assumptions (PM Zone 2), measured - PR objectui#11237 is on `main` as `8bab1571d` (`merge-base --is-ancestor` exit 0), and its diff and review were read first. - `usePredicateScope` is exported from `packages/react/src/hooks/useExpression.ts`, same package as `ActionProvider`: held. - The not-loaded reach is the one objectui#11212 measured (outside `MePermissionsProvider`: `/forms/:name`, standalone embeds). It was not re-measured through the real `App` here. ## Ablation (HEAD `17a2920d9`, fix committed first) Every mutation went through `ablation-replace.mjs` (the anchor had to hit once, the landing was proven by the anchor count and the blob hash, and each restore was proven as blob == HEAD with an empty `git diff HEAD`). Each leg ran 7 files, 100 tests: this pin, the objectui#11212 and objectui#4421 pins, `RowActionMenu.test.tsx`, two data-table row-action suites, and the throwaway probe. The red counts were written down before the run, and all eight matched: | leg | predicted | red | |:--|:--|:--| | A1 row-menu `fallback: true` back to `false` | row menu NOT LOADED, unbound | 2, exactly those | | A2 data-table `fallback: true` back to `false` | data-table NOT LOADED, unbound | 2, exactly those | | A3 `ActionProvider` binding off | runner GRANTED, header click GRANTED | 2, exactly those | | A4 `useActionEngine` shared-runner write off | 0; P1 prints HIDDEN | 0; P1 HIDDEN | | A5 `useActionEngine` standalone binding off | standalone quick_actions GRANTED | 1, exactly that | | A6 row-menu declared-gate check off | row menu NOT LOADED (companion), absent/blank | 2, exactly those | | A7 data-table declared-gate check off | same two, data-table | 2, exactly those | | A8 A3 and A4 together | A3's two, plus objectui#11212 quick_actions GRANTED and DENIED | 4, exactly those | All four mutated files ended as their HEAD blobs, and the tree was clean afterwards. ## Tests and gates (HEAD `17a2920d9`, clean tree, heavy runs through the shared verify lock) - New pin `packages/app-shell/src/providers/__tests__/currentUserCan-disabledLegs-11242.render.test.tsx`, 23 tests: both legs × three states, plus unbound and absent/blank arms; the built-in control × two menus × three states; the runner gate × three states, plus a real `page:header` click; and `record:quick_actions` with no provider × three states. Each arm has an ungated companion and its own action name. - Union, `pnpm exec vitest run` from the root, in chunks under `timeout 300`, every chunk exit 0: - `packages/plugin-grid/src/`: 174 files, 1595 tests, all passed (two halves: 87 / 758 and 87 / 837). A first single-chunk attempt hit the `timeout 300` wall after 125 files with 0 failures and was replaced by the two halves. - `packages/components/src/__tests__/` and `packages/components/src/renderers/complex/`: `Test Files 105 passed | 1 skipped (106)`, `Tests 1158 passed | 7 skipped`; then `Test Files 106 passed (106)`, `Tests 905 passed`. - `packages/react/src/` and `packages/app-shell/src/providers/`: `Test Files 129 passed (129)`, `Tests 1609 passed`. - 87 consumer test files outside those trees that name `ActionProvider`, `useActionEngine`, `RowActionMenu`, `DataTableRowActionItem` or `rowActionDefs` (app-shell views, plugin-detail, plugin-view, plugin-dashboard, core, …): `Test Files 87 passed (87)`, `Tests 957 passed`. - Root `scripts/__tests__/` (this diff edits markdown): `Test Files 177 passed | 2 skipped (179)`, `Tests 5371 passed | 2 skipped`. - Build: `turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`, 28 tasks successful (after the `plugin-grid^...` closure, 13 tasks). - `type-check` exit 0 for `@object-ui/core`, `@object-ui/react`, `@object-ui/components`, `@object-ui/plugin-grid` and `@object-ui/app-shell` (its `tsconfig.test.json` includes `src/**/*.test.tsx`, so the new pin is typed). - eslint on the 6 changed source and test files: 0 errors. Warnings per file equal the base counts; the new pin has 2 (`registered()`'s `ComponentType` of any, as in the objectui#11212 pin). - Exit 0: `node scripts/check-changeset-presence.mjs`, `check:control-bytes`, `check:new-line-citations`, `changeset:check`, `check:changeset-claims` (report-only), `check:pending-changeset-literals`, `check:doc-types`, `check:doc-fences`, `check:doc-example-ids`, `docs:check-links`, `node scripts/markdown-test-inputs.mjs --audit`, `check:action-forward-parity`, `check:test-path-roots`. `check-governed-queue-guard.mjs --test` over the 8 paths: NOT GOVERNED. - NOT MEASURED: `check:doc-snippets` and `check:doc-examples`. Both printed PRECONDITION NOT MET (exit 2): they need the 36-package docs build filter, and this tree has the 28-package closure. The doc edit adds prose only, with no fenced code. ## Acceptance notes (not filed) - **Open for the seat: the console's global runner.** `ActionProvider` binds only from a scope above it. The console's `GlobalActionRuntimeProvider` sits above `ExpressionProvider`, so probe P2 (provider above the scope, no engine block) still refuses a grant holder at this head. The routes that execute through that runner alone (dashboard, report, component, `/forms/:name`) answer `current_user` on `execute` gates only after an engine consumer has mounted, as before. No authored gate there was found to reach it: dashboard header defs carry only name, type, target and label. The options are in the report. - **Inline primary row action ignores `disabled`.** `RowActionInlineButton` (`RowActionMenu.tsx`) never reads the key. Probe P4: `disabled: true` and a denied `disabled: !current_user.can(…)` both render an enabled button, and only the runner refuses the click. This is the declared-gate class (objectui#3812), not this card's fault-policy class, and no producer of a primary `list_item` action with `disabled` was found. The docs above say "the row menu's items" deliberately. Carrier: none. - **`page:header` renders a BLANK `disabled` disabled.** Probe P5: `disabled: ' '` and a `{ dialect: 'cel', source: ' ' }` envelope render DISABLED. `''` and the empty-source envelope render enabled. `resolveDisabled` tests `!src` without trimming, and since PR objectui#11237 the blank branch of the row-predicate entry returns the header's `true` fallback. Core's `hasDeclaredPredicate` (objectui#3850 / objectui#3960) calls both values not declared, as `ActionRunner`, `action:button` and now both row legs do. So on a blank `disabled` the header and the row menu disagree. `containers.tsx` is outside this claim. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…rvested query input changes (objectui#10689) (objectstack-ai#11280) Fixes objectstack-ai#10689 Clause-②: no `ObjectGrid`'s load effect and `ListView`'s fetch effect now re-read when an input their query reads changes, and only then. This is the objectui#10664 family rule ("every input the query reads is in the effect's dependencies"), in PR objectui#10688's shape: each input is keyed by content, as a string compared by value (AGENTS.md objectstack-ai#10). What the query selects is unchanged; only when it re-reads moves. ## The rows, re-derived on `main` (`18d1a0abd3`) | Row | Input the query reads | In the effect's dependencies at base? | Change | |:--|:--|:--|:--| | grid (`ObjectGrid` load effect) | `conditionalFormatting` (predicate-operand harvest) | no | `predicateProjectionKey` | | grid | `rowActionDefs` (harvest) | no | `predicateProjectionKey` | | grid | `bulkActionDefs` (harvest) | no | `predicateProjectionKey` | | grid | `searchableFields` (`$searchFields`) | no | `searchFieldsKey` | | list (`ListView` fetch effect) | `conditionalFormatting` (harvest) | no | `predicateProjectionKey` | | list | `rowActionDefs` (harvest) | no | `predicateProjectionKey` | | list | `bulkActionDefs` (harvest) | no | `predicateProjectionKey` | | list | `searchableFields` (`$searchFields`) | yes (`schema.searchableFields`, by identity) | none: already re-reads on main, pinned only | ## The keys - `predicateProjectionKey`, in both files: `JSON.stringify(collectPredicateFieldRefs(listViewPredicates({ conditionalFormatting, rowActionDefs, bulkActionDefs })))`. It is the same harvest over the same three view-level inputs that the effect runs. It keys on the operand NAMES, the shape `groupingProjectionKey` already takes for `grouping` in the grid, so a rule's style or an action's label costs no round trip. The object-level `actions` / `userActions` the effect adds come from the object definition, not the view, and stay outside the key. - `searchFieldsKey`, in the grid: the `$searchFields` the query sends. It is `JSON.stringify(searchableFields)` while a term goes out, and `''` otherwise, because without a term the query does not send them. - No new helper. PR objectui#10688 added no named key helper: its keys are inline `JSON.stringify(...)` strings (`sortKey`, `optionsFilterKey`, `tableSortKey`) or a computed projection (`lookupExpandKey`). These two keys follow that shape. - The inputs are read through typed casts (`{ rowActionDefs?: readonly unknown[] }`, the way the grid already reads `bulkActionDefs` for its diagnostic), so the dependency lists hold simple names. The `rowActionDefs` read is the objectui#5091 NON-AUTHOR SURFACE exemption, as at the effects' own reads. ## Red first, then ablation Red first, at base. The two pin files, at their committed bytes, were run against the source at `18d1a0abd3`: - grid: `Tests 4 failed | 4 passed (8)`. The four defect rows were red (for the rule, `$select` stayed `[['id','name']]`), and the controls were green. - list: `Tests 3 failed | 4 passed (7)`. The three defect rows were red. The list `searchableFields` pin was green, because that row was already keyed on main. Ablation at head `1842dc0f83`, through `ablation-replace.mjs`. The anchor hit went 1 to 0, and the restore was proven: blob equal to HEAD, `git diff HEAD` empty. No `dist` leg was needed, because the pins import the components relatively from `src`. - grid, with `predicateProjectionKey, searchFieldsKey` dropped from the dependencies: `Tests 4 failed | 4 passed (8)`, the same four rows. - list, with `predicateProjectionKey` dropped: `Tests 3 failed | 4 passed (7)`, the same three rows. ## Pins (each counts `find` calls) - `packages/plugin-grid/src/__tests__/ObjectGrid.harvestInputsFetchKey-10689.test.tsx`: - A fresh mount with the rule reads once, with `$select` `['id','name','industry']`. - A rule, a row def or a bulk def added to a mounted grid re-reads exactly once, with its operand. - `searchableFields` changed under a term re-reads once, with the new `$searchFields`. - Controls that add zero reads: an equal re-render (new arrays with the same content, all four inputs); a style-only rule change; a `searchableFields` change with no term. - `packages/plugin-list/src/__tests__/ListView.harvestInputsFetchKey-10689.test.tsx`: the same cases for the list's three inputs, plus the already-keyed `searchableFields` row. ## Verification at head `1842dc0f83` - Build: `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-list^...' --filter '@object-ui/plugin-grid^...' build` exited 0. Both closures are needed: `plugin-list^...` alone does not contain `plugin-grid`'s `plugin-detail`, `data-objectstack` and `sdui-parser`. - Type-check: `pnpm --filter @object-ui/plugin-grid type-check` and `pnpm --filter @object-ui/plugin-list type-check` exited 0. `tsc -p tsconfig.test.json --listFiles` lists each new pin file. - Tests: - `pnpm exec vitest run packages/plugin-list/`: `Test Files 110 passed (110)`, `Tests 1208 passed (1208)`. - `packages/plugin-grid/` ran in two chunks. Its 177 test files were split 88 + 89, and the union was checked against the full list. Results: `88 passed (88)` / `766 passed (766)`, and `89 passed (89)` / `855 passed (855)`. - Checks, each exit 0: - `check:new-line-citations`: `VERDICT new-cross-file-line-citations: 0 new citation(s)`. - `scripts/check-changeset-presence.mjs`: 4 source files of 2 released packages, 2 changesets. - `changeset:check`, `check:control-bytes`, `check:action-forward-parity`, `check:i18n-keys`, `check:spec-symbols`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit` and `check:icon-record-names`. - Lint, narrowed to the four changed files (the tree-wide `pnpm lint` run is CI's): - None of the four is ignored by `eslint.config.js`: `eslint --print-config` resolves each one. - `eslint --format json` over them reported 4 files and 0 errors. - `ObjectGrid.tsx` and `ListView.tsx` carry the same warning counts as at base. - The narrowing excludes nothing. The config enables no type-aware linting (no `parserOptions.project` / `projectService`), and no rule under `eslint-rules/` reads the filesystem, so this diff cannot move the verdict on any untouched file. ## Acceptance notes - `ListView` names `schema.searchableFields` by identity in its fetch dependencies. A throwaway probe (deleted, never committed) measured it: an equal `searchableFields` in a new array, under a search term, re-reads (2 `find`). It is not edited here, for two reasons. The ruling says a row that already re-reads on main is pinned, not re-edited. And that effect's own comment records the objectui#4567 ruling that its by-identity dependencies stay, with stabilisation at the producer. No producer was measured rebuilding the array on every render. Carrier: none. - No README or guide changes: when a view re-reads is not documented behaviour for either block. ## Changesets - `.changeset/10689-grid-harvest-inputs-refetch.md` (`@object-ui/plugin-grid`: patch) - `.changeset/10689-list-harvest-inputs-refetch.md` (`@object-ui/plugin-list`: patch) Session: `session_0122Knsowci76D2rBWReCzzZ`, seat `domain:ui#1`, claim comment 5920079312. --- _Generated by [Claude Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…es the canvas on "Loading…" (objectui#11331) (objectstack-ai#11338) Fixes objectstack-ai#11331 Clause-②: no A Studio pillar whose draft load is cancelled, with no load to replace it, no longer leaves its canvas on "Loading…". The Interfaces race removed here is the one that keeps `Test (shard 2/8)` red on PR objectstack-ai#11323 (`StudioDesignSurface.canvasFrame.test.tsx`, "a canvas state with no preview shell keeps the wrapper frame"). That file is unchanged here. It goes green because the race is gone, not because it waits longer. ## The rule, one shape for the three draft loads Each draft-load effect (`InterfacesPillar`, `DataPillar`, `AutomationsPillar` in `StudioDesignSurface.tsx`) raised `loading` as its load started. Only the load's own `finally` lowered it, behind `if (!cancelled)`. **The rule now:** the cleanup of a run whose load has not settled takes back what that run's start claimed. Each run keeps a local `settled` flag, which its `finally` sets. Its cleanup runs `if (!settled)` and does the following: - **all three pillars:** `setLoading(false)`. - **Data only:** it also releases the load-once claim (`loadedNameRef.current = null`). This is the same rule applied to a second claim, the only other claim a run's start makes. The Data run's start claims two things: `loading` and `loadedNameRef`. The measurement below shows that lowering `loading` alone leaves the Data case stuck. **Why it cannot raise a second spinner or flash stale content:** when a replacement load follows, the old run's cleanup `setLoading(false)` and the new run's `setLoading(true)` happen in the same passive-effect flush, so React batches them into one render. Even without that batching, the canvas's `loading || !draftLoaded` arm (or `!objLoaded` in Data) keeps the new item on the spinner until its own load is installed, because the objectui#11272 buffer stamp names the item it was loaded for. The stamping (`draftFor`, `ifDirty`, `hasDraft`, the `!draftLoaded` arm and its `error && !loading ? null` rule) is unchanged, and the canvas branch order is unchanged. ## What was measured (Zone 2) 1. **Interfaces, red first on `origin/main` `5262f7dd3f`.** The gated probe is now a committed pin. It mounts the real `InterfacesPillar` with `canvasFrame.test.tsx`'s mocks, holds the dashboard leaf's `layered` read on a gate, and clicks the report leaf while that read is in flight. On `5262f7dd3f` the race row fails with the DOM caption `Sales report` over a canvas body that reads `Loading…`. A scratch probe asserted only the issue's "after the gate opens" column, and on the ablated tree it fails the same way after the gate opened. The control row, a click after the dashboard grid rendered, shows "cannot be previewed or designed here" on both trees. 2. **Data: reached through the component.** The pin re-renders the pillar with a new client object between the start of the load and its release. On `5262f7dd3f` the object stays on `Loading…` after the gate opens. The cause is `!objLoaded`, not `loading`. The cancelled load installed nothing, and the re-run bailed on `loadedNameRef.current === loadKey`. **Lowering `loading` alone does not fix this site** (ablation leg D1 below: the race row stays red). Releasing the unsettled claim is what fixes it. A claim whose load settled is kept, and a pin row says so: a new client after the object loaded issues no second `layered` read. 3. **Automations: not reachable through the component, so it has no row.** Its effect returns early only on `!current`, and the pillar never sets `current` back to null once a flow is open. Its three `setCurrent` writes are the list effect's `c ?? deepLinked ?? items[0] ?? null`, a create and a rail click, all of them non-null once a flow is open. So a cancelled flow load always has a replacement. On `!current` the canvas's `!current` branch comes before the loading arm, so no spinner is visible with no leaf. That is a reading of the source; nothing in the tree re-derives it. The cleanup takes the same shape here as at the other two sites. 4. **One shape:** the `settled`-gated cleanup above, chosen over clearing in every early-return branch. A branch-by-branch clear covers only the early returns that exist today, and it does not cover the Data bail at all. The cleanup is the one place every cancelled run passes through. ## Pins: `StudioDesignSurface.cancelledLoad-11331.test.tsx` | row | `5262f7dd3f` (no fix) | this head | |:--|:--|:--| | Interfaces race: report leaf clicked while the dashboard load is in flight | **red** (`Loading…` under the `Sales report` caption) | green | | Interfaces control: the same click after the dashboard rendered | green | green | | Data race: a new client while the object load is in flight | **red** (`Loading…`, no `Add field`) | green | | Data control: the same gated load with no new client | green | green | | Data guard: a new client after the object loaded reads nothing again | green | green | ## Ablation (fix committed first, each leg mutated through `ablation-replace.mjs`, restore proven by blob hash and an empty `git diff HEAD`) | leg | mutation | red rows | green rows | |:--|:--|:--|:--| | I | Interfaces cleanup line removed | Interfaces race, and the scratch probe's after-gate race row | the other 4 pin rows, and the probe's control | | D1 | Data keeps only `setLoading(false)`, so the claim is not released | Data race | the other 4 pin rows, and both probe rows | | D3 | Data releases the claim even when the load settled | Data guard (`layered` read twice) | the other 4 pin rows, and both probe rows | ## Local verification (head `7b70a05236`) - `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build`: exit 0. Then `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `--listFiles` shows the test project compiles the new pin. - `pnpm exec vitest run packages/app-shell/src/views/studio-design/`: `Test Files 85 passed (85)`, `Tests 496 passed (496)`. - Repeated runs, one vitest process each: the new file passed 10 of 10 runs and `StudioDesignSurface.canvasFrame.test.tsx` passed 10 of 10. - Gates, each at exit 0: `check:new-line-citations` (0 new), `check-changeset-presence`, `changeset:check`, `check:control-bytes`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:changeset-claims` (report-only; the pending changeset it names is about chunk co-tenancy, which this diff does not move), `check:pending-changeset-literals`. - ESLint on the two changed files only (`--no-inline-config`): the new test has 0 findings. `StudioDesignSurface.tsx` has the same rule-by-rule counts as `5262f7dd3f` read through `--stdin`, which is 1 error (`react-hooks/static-components`) and 18 warnings, so nothing was added. The config enables no type-aware linting, so this diff cannot move a verdict in an untouched file. The repo-wide lint is CI's. - ⛔ No timeout was raised and no `heavyDomTests` entry was added. ## Acceptance notes - Changeset: `.changeset/11331-app-shell-cancelled-load-spinner.md`, `@object-ui/app-shell` `patch`. - CI was not awaited (the seat owns convergence). `Spec Main Shape Gate` is expected red from objectui#11330, which this diff does not touch. - Reach of the Data site: the pin reaches it through a `useMetadataClient` re-mint, which the hook's `useMemo` can hand out (AGENTS.md objectstack-ai#10). No console flow that re-mints the client mid-load was exercised. Session: `https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ` --- _Generated by [Claude Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_ Co-authored-by: Claude <noreply@anthropic.com>
… views (objectui#11336) (objectstack-ai#11620) Fixes objectstack-ai#11336 Clause-②: no ## What changed `@objectstack/spec` 17.6.0 (objectstack-ai/objectstack#21072, `c27404f0a9`) translates the `listViews` an object document embeds on the server, from `objects.OBJECT._views.KEY` and with a published edit kept over the packaged catalog. The console still ran its own catalog over those labels a second time. This PR counts such a view as served, so the switcher tab and the breadcrumb draw its label as given. - `packages/app-shell/src/hooks/useServedViewItems.ts` - `isServedView(served, objectName, viewId)` answers true for a `/meta/view` document named `viewId`, as before. It now also answers true when `viewId` is a key of the served `objectName` document's own `listViews`. - That document is read before the merge, from `useMetadata().getItemsByType('object')`. The merged `listViews` the label sites hold mixes the document's own entries with view documents and a container's expansion, and only the document's own entries were translated by `translateObject`. - Only the canonical `listViews` key is read, and the entry must be a record. The server translates no other spelling, so a view a stored document embeds under the legacy `list_views` keeps the bundle. The probe below measures this. - `useServedViewItems()` returns both reads as one record. It is held in a module-level `WeakMap` keyed on the two cache arrays, so its identity depends on the payloads, never on a React memo (AGENTS.md objectstack-ai#10). - The docblock bullet "The server's `translateObject` does not translate those" is corrected. - `packages/app-shell/src/views/ObjectView.tsx` (`viewTabLabel`, `viewOriginLabel`) and `packages/app-shell/src/layout/AppHeader.tsx` (the crumb) now pass the object name to the predicate. Their comments now name both reads. - Tests: - The two existing objectstack-ai#11295 suites now serve the `/meta/object` document as well, and their stale prose is corrected. - The old "object-embedded view is named by the bundle" control now uses a view only the client derives (a stack container's expansion). - New objectui#11336 cells cover the tab and the crumb. - A unit file pins the predicate's edges and the hook's identity. - `.changeset/11336-served-embedded-listviews.md`: `@object-ui/app-shell` patch. No export, prop, type member or i18n key is added to any package entry. `useServedViewItems` and `isServedView` are not reachable from `@object-ui/app-shell`'s entry: `exports` maps only `.` and `./styles.css`, and both names are absent from `src/index.ts` and `src/hooks/index.ts`. The control `useRecentItems` has 2 hits in `src/index.ts`. ## Premise check (measured on `origin/main`, not taken from the card) - **The label sites read the served object.** `ObjectView` and `AppHeader` receive `objects` from `useMetadata().objects` (`AppContent`). That getter is `mergeViewsIntoObjects(readType('object'), readType('view'))`. `getItemsByType('object')` returns the cache's own `/meta/object` items, mutated only by `normalizeSchemaReferenceKeys`, which touches field reference keys. The merge copies the served document's own `listViews` entries under their record keys, and those keys win collisions. So an embedded tab's id is the key the served document carries. - **The server translates them.** A probe ran against the installed `@objectstack/spec` 17.6.0 (`system.translateObject`, locale `zh-CN`, a catalog with `objects.sys_account._views.mine.label`): | Case | Served label | | --- | --- | | `listViews.mine` | `我的链接` | | sibling `other`, no catalog entry | `Other` | | `mine` carrying an org edit (`Links I own`), with `packagedBase` | `Links I own` (the catalog loses) | | legacy `list_views.mine` | `My Links` (untranslated) | - **Not translated by any server:** a stack container's expansion. `translateView` does not walk a container's nested `listViews`, so that kind still goes through the bundle. ## Evidence (head `0a2c2af`) - `pnpm exec vitest run` on the three touched test files (repo-root spelling), before the merge at `5b212d6`: `Test Files 3 passed (3)`, `Tests 35 passed (35)`. The full-package run below covers them again on the merged head. - **Reverse verification**, run after commit through `ablation-replace.mjs` (anchor hit 1 time; blob changed, then restored equal to HEAD; `git diff HEAD` empty afterwards). The object-document branch was cut (`if (!objectName) return false;` became `return false;`), and the predicted cells went red: `Tests 7 failed | 28 passed (35)`. - Red: the two ObjectView "published edit the object read served" cells, the two AppHeader cells, and three unit cells (embedded key answers; key answers only under its object; identity record answers after a refetch). - Green on both sides: the zh-CN served-string cells (the catalog says the same thing), the no-catalogue and container controls, and every objectstack-ai#11295 cell. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0 on the merged head, after the `@object-ui/app-shell^...` dependency closure was built. `tsc -p tsconfig.test.json --listFiles` lists all three touched test files. - `pnpm exec vitest run packages/app-shell/` on the merged head: `Test Files 1000 passed | 1 skipped (1001)`, `Tests 9930 passed | 9 skipped (9939)`, exit 0. The run held the shared verify lock; wall-clock figures are omitted because the box is shared. - ESLint on the six touched source and test files: 0 errors. Per-file warning counts equal `fd060f0767`'s (ObjectView 174, AppHeader 22, the two objectstack-ai#11295 suites 3 and 17, the hook 0). The new unit file has 0 warnings. - Root gates run locally, each exit 0: `check-changeset-presence`, `check-changeset-no-major`, `check:changeset-claims` (with the changeset committed), `check:pending-changeset-literals`, `check:new-line-citations` (0 new), `check:control-bytes`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:unreferenced-sources`. CI runs the rest. ## Acceptance notes - **Older servers.** A console with this change, talking to a server whose spec predates `c27404f0a9` (17.0 to 17.5), draws embedded views' labels as authored. Today the console's second pass translates them. I found no version gate in app-shell: the only server-capability signals it reads are the runtime config's `features.*` flags, and none of them says "embedded listViews are served translated". The CLI-shipped pairing is unaffected, because `@objectstack/console` is built at the objectui SHA pinned per framework release. The exposed pairing is `@object-ui/console` run on its own cadence against an older 17.x server. No gate is added here; the question is in the report. - Observation, out of this card's surface and not filed: `useNavTargetLabel` (nav-entry labels) passes a view's label through `viewLabel` without asking `isServedView`, for both channels. No reproduction was run, and no carrier is known. - `objectui#11303` kept the console path for embedded `listViews` because the server did not translate them then. That reason is gone with 17.6.0, which `pnpm-lock.yaml` resolves. Implemented by the dispatched `os-dev` agent, session `https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL`. --- _Generated by [Claude Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…creen field's Min / Max save as numbers (objectui#11664) (objectstack-ai#11670) Fixes objectstack-ai#11664 Clause-②: no ## What changes The flow designer edits a screen node's `fields` as an object list, one row per screen field. The engine's screen descriptor publishes the item properties `min` and `max` as `type: 'number'`, but the list had no number column, so both were text cells. Authoring Min 1 / Max 10 saved `min: '1'`, `max: '10'`, and every run of the flow then failed at the screen node against `ScreenFieldConfigSchema` (`z.number()`). Triage's direction (comment 6007030030) is applied as ruled: - **A number kind.** `FlowConfigColumn['kind']` gains `number`. `columnsFor` maps a `number` / `integer` item property to it by reusing the top-level `scalarField` mapping, so there is no second number detector. An enum still wins and stays a select, as at the top level. - **A number cell that commits a number.** The cell is the same `Input type="number"` control the top-level number field renders, flushed on blur like the text cell beside it. A typed `0` commits `0`. An emptied cell commits no key at all: not `''`, `null` or `NaN`. An entry the browser cannot read as a number commits nothing, as in the top-level number field. - **Stored numbers stay numbers.** `toRows` and `rowsToList` keep a stored number a number. A string already stored in a number column is kept verbatim until the author types over it. Stored data is not coerced, and the screen contract still refuses that string. Other columns' strings are unchanged. ## A second reason pin 2 failed, found in the live console Driving the real designer against an objectstack `main` backend showed that triage's pin 2 (re-saving a code-authored screen with `min: 0` keeps `0`) failed for a second reason, which neither the card nor a synchronous unit test could see. The inspector renders the hand-written fallback columns until `GET /api/v1/automation/actions` answers, then the engine's columns. `FlowObjectListField` built its rows against the first set, which has no `min` / `max` column, and rebuilt them only when the value changed. So editing any cell of the first selected screen field saved it without `min` and `max`, and without `options`, `defaultValue`, `placeholder`, `inlineHelpText` and `reference`. This happens on `main` too: there the bounds were dropped, not turned into strings. The fix is in the same file, inside the claimed surface. Each row keeps the stored item it was read from. When the column set changes, a column the rows do not hold yet, or one whose kind changed, reads its cell from that item. Every other cell is kept, including unflushed typing. Nothing is written when the schema arrives; the author's next edit writes. The effect is keyed on a string of the columns' keys and kinds, not on the array's identity (AGENTS.md objectstack-ai#10). The screen pins now run on the real descriptor hook with a late answer, in the order the console sees. ## Tests Final head `d8002fe` unless a row says otherwise. Each exit code was written to a file as it ran. | Run | Result | |---|---| | `pnpm exec vitest run` on the new `FlowNodeInspector.screenNumberBounds-11664.test.tsx`, the four `FlowObjectListField.*` suites and `json-schema-to-fields.test.ts` | 6 files, 56 tests passed | | `pnpm exec vitest related --run` on `FlowObjectListField.tsx` | 113 files, 1021 tests passed | | `pnpm exec vitest run packages/app-shell/` at `14f9b48` (before the merge) | 1012 files passed, 1 skipped; 10043 tests passed | | the same at `91d45ca` (after merging `main` `f9f4a62`) | 1013 passed, 1 failed, 1 skipped. The failure is outside this diff; see Acceptance notes. | | `turbo run build --filter='@object-ui/app-shell^...'`, then `pnpm --filter @object-ui/app-shell type-check` / `lint` / `build` | exit 0 each. `type-check` runs `tsconfig.test.json`, which includes `src/**/*.test.tsx`. `lint`: 0 errors. The one warning in `FlowObjectListField.tsx` (`react-hooks/refs` at the rows map) is also on `main`. | | `check:control-bytes`, `check:new-line-citations`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:spec-symbols`, `check:designer-field-key-parity`, `check:i18n-keys`, `check:i18n-designer-parity`, `check:installed-pin-claims`, `check:unreferenced-sources`, `check:action-forward-parity`, `check:handler-key-reads`, `check:shell-escape-residue`, `check-changeset-presence.mjs`, `check-changeset-no-major.mjs` | exit 0 each | The final suite was narrowed, and the narrowing is declared here. After `91d45ca` the commits touch only `FlowObjectListField.tsx`, its test file and the changeset. `vitest related` selects every test file whose import graph reaches that file. CI runs the full suite. **Ablations.** Each behaviour this PR adds was ablated at `d8002fe` with objectstack's `scripts/ablation-replace.mjs` in WRAP mode. Every leg landed on disk with its anchor going from 1 to 0, and every restore was proven: the blob equals `HEAD` and `git diff HEAD` is empty. Every leg turned the pinned file red: | Leg: what was removed | Red | |---|---| | `columnsFor` number mapping | 9 of 10 | | a number cell reads a stored number as a number | 7 of 10 | | a stored string coerced with `Number()` instead of kept | 1 of 10 | | `rowsToList` commits a number | 8 of 10 | | `rowsToList` keeps a stored string | 1 of 10 | | an empty cell commits nothing | 4 of 10 | | the cell parses its input (raw string committed instead) | 4 of 10 | | a non-finite parse never becomes a string | 1 of 10 | | the cell is `type="number"` | 1 of 10 | | the late-column re-read | 7 of 10 | | the kind-change half of that re-read | 1 of 10 | **Live check.** The console ran from this worktree (vite, port 5216) against objectstack `main` `faf8dce482` (the showcase in its own worktree with `--fresh`, port 4116). The browser was Playwright Chromium at `/opt/pw-browsers/chromium`, signed in as the seeded admin with the session cookie. A flow `start` → `screen` → `end` was seeded through `PUT /api/v1/meta/flow/NAME`. The screen node was edited in the designer and published, the flow was read back through the API and run with `POST /api/v1/automation/NAME/trigger`. For the before leg, the three source files were swapped to their `fc3c2cc` bytes and restored afterwards, with the restore proven. | | Authored Min 1 / Max 10 | Code-authored `min: 0`, `max: 5`, Label edited | |---|---|---| | before | cells are text inputs; saved `"min":"1","max":"10"`; trigger answers 400 `FLOW_FAILED`: "screen 'ask': config does not satisfy the screen contract — config.fields[0].min: Invalid input: expected number, received string; config.fields[0].max: …" | cells empty; saved without `min` / `max`; trigger pauses at the screen with no bounds | | after | cells are number inputs; saved `"min":1,"max":10`; trigger pauses at screen `ask` with `min: 1, max: 10` | cells show 0 and 5; saved `"min":0,"max":5`; trigger pauses with `min: 0, max: 5` | The `configSchema` this backend served for `screen` equals the test's transcribed `SCREEN_CONFIG_SCHEMA`, compared as canonical JSON. **Clause-② fence.** On the final build, a walk of relative imports from `packages/app-shell/dist/index.d.ts` reaches 164 declaration files, 22 of them under `views/metadata-admin`. None is `flow-node-config.d.ts`, `FlowObjectListField.d.ts`, `json-schema-to-fields.d.ts` or the inspectors' `index.d.ts`. The built `flow-node-config.d.ts` carries the new `'number'` member. No export, entry-reachable prop or type member, or language-pack key is added. ## Acceptance notes - **Zone 2 readings.** - **1.** Confirmed as the PM read it, and the existing mapping is reused. - **2.** The top-level control, `InspectorNumberField`, is a label above an `Input type="number"` that commits on every keystroke. The cell reuses that input and its empty-commits-nothing rule, but not the wrapper: the wrapper's label would duplicate the row label, and its per-keystroke commits differ from the row's flush on blur. No i18n key is added; the placeholder is the column's schema description, as for every other cell. - **3.** An empty cell is an absent key, and `0` is `0`. A browser number input reports `''` for an entry it cannot read, so that entry commits nothing; typed over a stored number, it removes that number on blur, exactly as the top-level field does. - **4.** A stored string in a number column is handed to the input as-is. A numeric string such as `'1'` shows as 1; a non-numeric one shows a blank box. The row keeps the string either way until typed over (pinned). - **5.** The engine source is pinned with the transcribed descriptor. The offline table lists `fields` columns (Name, Label, Type, Required, Visible when) but no `min` / `max`, so it has no number cell. - **6.** Measured; see the live check above. - **Observations, not filed** (no public door measured): - **Offline table.** When no `configSchema` is served, a re-save of a screen's `fields` drops every key the hand-written columns do not list: `min`, `max`, `options`, `defaultValue`, `placeholder`, `inlineHelpText`, `reference`. Measured with a scratch test at `14f9b48`. A backend that publishes the screen descriptor leaves that table once its answer lands. Carrier: none. - **Untyped properties are text columns.** The descriptor publishes `options[].value` and `defaultValue` with no `type`, so they are text columns, and a stored number there still saves as a string through `String(v)`. Both are `z.unknown()` in the spec, so nothing refuses the string. This is outside the ruling, which covers number columns. Carrier: none. - **A full-suite failure after the merge.** At `91d45ca`, `AppContent.deniedVsUnpublished.test.tsx` ("that way back follows the DECLARED landing") failed once on a network-escape guard: reads of `/api/v1/meta`, `/meta/package` and `/meta/diagnostics` under full parallel load. Alone it passes 15/15. This diff does not touch that file. `main`'s `f9f4a62` changed `HomePage.tsx`. Carrier: none. - **Not in this PR.** No change in objectstack. objectstack#21898's save-time judge is that card's own business. Triage's enumeration pin for a third face of the family belongs to a later card. --- _Generated by [Claude Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…699) (objectstack-ai#11750) Fixes objectstack-ai#11699 Clause-②: no This is the card's remaining half. The concurrent half landed in PR objectui#11745. On open, the record page sent its own `$expand` read of the record (`GET /api/v1/data/OBJ?populate=…`) twice, one after the other. It now sends it once. Measured on a real stack below. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8`. ## What this does Both sides of the seam, per objectui AGENTS.md objectstack-ai#10. The producer side is covered by the claim amendment on the card (comment `6026121402`). - **Consumer: `RecordDetailView`'s record-load effect keys on what the read sends.** It depended on `effectivePage`, `objectDef` and `perms`, three object identities. Read from the code, the effect uses `effectivePage` only as a yes/no guard, `objectDef` only as `objectDef?.fields` passed to `buildExpandFields`, and `perms` only through `isLoaded` and `checkField`. It now depends on `hasPage` (a boolean) and `pageRecordExpand`, the field-level-security-gated expand list as a JSON string. Both are computed during render from the same inputs. The objectui#7230 gate is unchanged in substance, and its rationale moved with the code: an unanswered policy filters nothing, and an answer that narrows the list re-reads the record without the denied relation. - **Producer: `MetadataProvider`'s `objects` list keeps its identity.** The context value's `objects` getter ran `mergeViewsIntoObjects` + `attachInlineSubforms` on every read, and both wrap every object they touch in a new object. The composed list is now cached in a module-local `WeakMap` keyed on the two stored arrays it is built from (`entry.items` for `object` and for `view`). The same arrays give the same list and entries; a replaced array gives a new list carrying the change. Neither helper's signature changes, and nothing is exported. ## Measurement Environment: objectstack `main` at `60ccda5a`, the showcase app (`objectstack dev --seed-admin --fresh`, own port, temporary SQLite). The console is a production `vite build` of this worktree with `VITE_BASE_PATH=/`, served by `vite preview` with `/api` proxied to that backend. Chromium headless 1440x900, seeded admin. One warm-up visit, then 4 full reloads of a `showcase_task` record page per build, counting requests with Playwright `request` events and waiting 6 s after `load`. Both builds render the record: the page's `h1` reads the task's title. | per reload | base `055d350` (instrumented) | this branch `c9324d2` | |:--|:--|:--| | `GET /api/v1/data/showcase_task?populate=…&top=1&filter=…` (the page's own read) | 2, in sequence, on 4 of 4 | 1, on 4 of 4 | | `GET /api/v1/data/showcase_task/ID` (the details block's read, shared since PR objectui#11745) | 1 | 1 | | all API requests | 40, 40, 40, 40 | 39, 39, 39, 39 | The `populate` URL (expansion and filter) is byte-identical between the two builds. ### Which dependency changed between the two reads (the order's mechanism hypotheses, as measured) The base build carried temporary instrumentation. In the effect, it logged which dependencies differed from the previous run. In the provider's getter, it tagged each object handed out with the getter read and the context `version` that produced it, and whether it was the stored item. It also logged each metadata type as it landed. The instrumentation was reverted with `git checkout HEAD --` before any fix was written: `git diff HEAD` empty, 0 markers, blob equal to `HEAD` for both files. 1. **The dependency list and what the effect reads:** confirmed by reading (see "What this does"). 2. **`objectDef`, `perms`, or both:** `objectDef` only, on 4 of 4 reloads. The second run's changed set was exactly `objectDef`. `perms`, `effectivePage`, `objectName`, the record id, `dataSource` and the invalidation nonce were the same values, and `objectDef.fields` was the same object. The new definition was JSON-equal to the old one. The policy had loaded before the page mounted, and the assigned record page (`showcase_task_detail`) had landed before the effect's first run, so neither moved here. 3. **The field-level-security gate on `$expand`:** holds. `RecordDetailView.expandFls-7230.test.tsx` is unchanged and green. The new pin adds the case that file does not cover: a policy that arrives after the read and denies a relation reads the record again without it. 4. **The producer:** located. Both definitions came out of `MetadataProvider`'s `objects` getter at the same context `version` (7), from different getter reads, and neither was the stored item. A host re-render re-read the getter and got a new wrapper for unchanged metadata. Before keying the producer cache on identity, every write to a stored `object` / `view` array was read: - In the provider, every write replaces the array: a landed fetch, `.filter` on a by-name invalidation, `[]` on a whole-type invalidation, and the `app`-only session seed. The provider has no in-place mutator on those arrays. Its only `.push` targets a local map inside `attachInlineSubforms`. - The arrays also leave the provider through `readType`, `getItemsByType` and `ensureType`'s promise. A per-file census of the 47 non-test files that read them found 0 in-place mutators (`sort`, `push`, `splice`, `reverse`, index or `length` assignment) on any name bound to a metadata list. A synthetic positive control was flagged 2 of 2. - An inline-chain census (`.objects.MUTATOR(`) found 0 hits, with a control flagged 2 of 2. - In-place top-level writes to stored definitions in `app-shell`, `react` and `plugin-detail` sources: 0 hits, with a control flagged 1 of 1. Nothing re-checks this census. The cache's own comment says so (AGENTS.md objectstack-ai#9). ## Tests - `packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx` (the landed pin, extended; 7 tests). The record-open count now includes the page's own `$expand` read: once, with both relations. New cases, each delivering an identity change the old dependency list re-read on, and counting that read: - an equal definition as a new object (the measured trigger), and as a new object all the way down (what a byte-identical refetch hands over); - an assigned page landing after the record was read against a synthesized one; - a forced discard of the `useMemo`s over `objectDef` (marker-scoped proxy, as in `plugin-list`'s `ListView.discardedExpandFieldsMemo.test.tsx`), armed before mount; - a permission answer arriving after the read that leaves the expansion as it was. - Two live controls: a definition whose relations change, and a permission answer that denies a relation, each read again with the new expansion. - `packages/app-shell/src/providers/__tests__/MetadataProvider.composedObjectsIdentity-11699.test.tsx` (new, 5 tests). It mounts the real provider over an adapter double that serves a new array per fetch: - re-reading at the same version keeps the list and its entries; - another type landing (a new context value) keeps them; - replacing the stored objects, or the stored views, gives a new list carrying the change (two live controls); - a forced discard of the context value's `useMemo` keeps the list. The case first shows the discard reached the provider: the context value IS a new object. - The proxy discards every armed `useMemo` but not `useCallback`. That is a measured choice; see Acceptance notes. Runs (from the worktree root, through the shared verify lock): All at `be32ead` unless named otherwise. - `pnpm exec vitest run --maxWorkers=2 packages/app-shell/`: `Test Files 1052 passed | 1 skipped (1053)`, `Tests 10301 passed | 9 skipped (10310)`, exit 0. The log carries happy-dom `NetworkError` traces from iframe navigations to `localhost:3000` app URLs. They come from a preview test and vitest does not count them. - The `apps/console` test files that read the metadata provider or `useMetadata` (the producer change's consumers outside the package): `Test Files 17 passed (17)`, `Tests 184 passed (184)`, exit 0. - The two pins plus the unchanged 7230 pin, together: `Test Files 3 passed (3)`, `Tests 18 passed (18)` (at `6892ddb`; the two later commits are type-only and the changeset). - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0, 0 `error TS`, after the closure build `turbo run build --filter=@object-ui/app-shell^...` (`28 successful, 28 total`). `tsc -p tsconfig.test.json --listFiles` includes both new test files (2 of 2). - Lint: `pnpm exec eslint --format json` on the four touched files: 4 files, 0 errors. Both touched sources have the same warning count as on `055d350` (`RecordDetailView.tsx` 110, `MetadataProvider.tsx` 52), and both test files have 0. This is a declared narrowing to the touched files: `eslint.config.js` sets no type-aware parser options (`projectService` / `parserOptions.project`), so this diff cannot change a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. ## Ablations Each leg mutated committed `HEAD` (`be32ead`) through objectstack `scripts/ablation-replace.mjs` in WRAP mode. Each anchor hit once and the blob changed. Each restore was proven by blob == `HEAD` and an empty `git diff HEAD`. The mutated files are imported by relative path from the pins, so no `dist` is involved. Directions were predicted before the runs, and each leg came out as predicted. 1. **Consumer:** the old dependency list put back (`[effectivePage, objectName, pureRecordId, dataSource, objectDef, recordInvalidationNonce, perms]`). 4 red, exactly the four identity cases, with `to have a length of 1 but got 2` / `but got 3`. 9 green: both live controls, the count test, and all six tests in the 7230 pin. 2. **Producer, no cache:** the old getter body put back. 3 red (same-version, another type landing, the discard). 2 green (both live controls). 3. **Producer, a cache held on the context value object** (what a React-memo cache amounts to): 2 red (another type landing, the discard). 3 green (same-version re-read, both live controls). This leg shows the discard case can fail for the reason it exists. ## Gates (exit codes captured before any pipe) At `be32ead`: `check:changeset-claims` 0 (no pending changeset names a file this change touches; this changeset does not negate its own package) · `check:pending-changeset-literals` 0 · `check-changeset-no-major` 0 · `check-changeset-presence` 0 (`4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)`) · `check-changeset-overwrite` 0 (`1 changeset(s) added, 0 modified, 0 deleted`) · `check:control-bytes` 0 · `check:test-path-roots` 0 · `check:new-line-citations` 0 (`VERDICT new-cross-file-line-citations: 0 new citation(s)`) · `check:vi-mock-specifiers` 0 · `check:vi-mock-inherit` 0 · `check:vi-mock-override-shape` 0 · `check-governed-queue-guard.mjs --test` over the 5 paths: `NOT GOVERNED`, exit 0. NOT MEASURED: `check:readme-exports`, reason: no export changes and no README changes. The i18n gates, reason: no locale pack changes. The full `pnpm test` farm and the repo-wide `pnpm lint` are left to CI. ## Declaration delta No export, prop, type member or language-pack key changes. `composeObjects` and its `WeakMap` are module-local. `mergeViewsIntoObjects` and `attachInlineSubforms` keep their exported signatures. The changeset declares `@object-ui/app-shell: patch` (`.changeset/11699-record-load-once.md`), with the measured before and after. Neither the package README nor `content/docs` documents `useMetadata` / `MetadataProvider`, so no doc changes. ## Acceptance notes - **A latent AGENTS.md objectstack-ai#10 hazard in the same provider, not changed here** (it is outside the amended surface, the composed `objects` list). The provider's preview-mode effect lists `bump`, a `useCallback`, as a dependency. After mount, any re-run clears the whole metadata cache. In the provider pin's first draft, a forced discard of every `useCallback` emptied `objects` this way. React does not discard on its own in this tree, so this is dormant. Recorded on the card, not filed. - **A refetch that answers byte-identical metadata still installs new stored arrays**, so the composed list is new then. That is AGENTS.md objectstack-ai#10's refetch half, reached by an explicit `refresh()` after a publish or install. The record page no longer re-reads on it, because it keys on data. Other consumers keyed on definition identity may recompute. Not measured here. - **A trade-off, by construction:** the record page no longer re-reads its record when the object definition changes without changing its relations, for example a publish that adds a plain field. Before, any new definition object re-read it. Data changes still re-read through the invalidation nonce and the refresh button. - `RecordDetailView.expandFls-7230.test.tsx` keeps its header line "Stable stub identity — `perms` rides the record-load effect's dependency list". After this change, the gated list rides it instead. The order asked for that file unchanged, so the line stays as it is. - Out of this card and unchanged in both builds: `runtime/config` and `get-session` go out twice per reload; `sys_user_preference` is PATCHed twice (objectui#11678). Two `security/explain` POSTs per reload in both builds; their bodies were not compared here. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ys (objectui#11772) The ledger's effect read the memoised `nodes` / `edges`; it now reads the draft's own arrays, per AGENTS.md #10. Recording is idempotent, so behaviour is unchanged. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…heck (objectui#11943) (objectstack-ai#11962) Part of objectstack-ai#11943 Clause-②: no The Sort picker no longer offers a field the caller may not read. Choosing such a field sent a sort the server refuses with 403, and the list went blank. Left for the follow-up: an unreadable field that the current sort already names stays listed exactly as before, unmarked and still choosable in the picker's other rows, because listing it as removable only needs a new `SortBuilder` prop (objectui#11943's ruling B; the seat returns that half to triage). ## What changes - **One predicate.** objectui#11925's read moves out of the `filterFields` memo into a module-level function in `ListView.tsx`, `canReadField(perms, objectName, field)`. It is `perms.checkField(objectName, field, 'read')` behind the same `isLoaded` gate as the column list. The Filter panel's list and the Sort picker's list both call it. The filter list behaves exactly as before: its seven objectui#11925 pins run unchanged and stay green. - **`sortFields` asks it first.** A field the user may not read is dropped unless the current sort names it. Before the permission answer loads nothing is filtered, matching `effectiveFields`. A dropped lookup no longer raises the relational hint, which explains a missing relation the user could read. - **The `effectiveFields` comment states what is true.** The old one said unreadable columns also disappear from the hide-fields popover, the filter and sort builders and `$select` because they are filtered there. None of those lists is built from `effectiveFields`. The new comment names which lists are built from it, which ask the read themselves, and the Sort picker's in-use exception with its follow-up. **Route note.** The seat asked for the predicate at component scope inside `ListView`. It lives at module scope in the same file instead. A component-scope function would be either a `useCallback` identity in two `useMemo` dependency lists, which AGENTS.md objectstack-ai#10 forbids, or a per-render closure that `react-hooks/exhaustive-deps` flags in both memos. As a plain function of `perms` and `schema.objectName`, both of which the memos already list, it needs neither. ## Pins `packages/plugin-list/src/__tests__/ListView.sortFieldRead-11943.test.tsx` reads the real `SortBuilder` dropdown through `MePermissionsProvider`: 1. an unreadable field not in the sort is not offered; 2. control: full read, and no provider at all, both list today's fields in today's order; 3. before `isLoaded`: a provider refetching with the restricted answer still held (`checkField` would deny, `isLoaded` is false) lists everything, as the columns do; 4. an unreadable field already in the sort stays listed, with its row named rather than blank. This pins the known half-state, and the follow-up will change it; 5. an unreadable lookup does not raise the relational hint, and a readable one still does. **Reverse check**, on the committed fix `985ec3c`, each mutation landed and restored through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `0edf2348fdde` changed, restore blob equal to HEAD and `git diff HEAD` empty), running the new file plus the objectui#11925 file: | Mutation | Predicted red | Observed | |---|---|---| | M1: delete the sort read line (the fix reverted) | pins 1, 3, 5 | `Tests 3 failed / 9 passed (12)`: pins 1, 3, 5 | | M2: drop the `isLoaded` leg of `canReadField` | pin 3 | `Tests 1 failed / 11 passed (12)`: pin 3 | | M3: drop the in-use exception from the sort read | pin 4 | `Tests 1 failed / 11 passed (12)`: pin 4 | The seven objectui#11925 pins stayed green under all three mutations. ## Gates On HEAD `985ec3c`, through the container's verify lock where heavy: - `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-list^...' build`: `VERDICT command-exit 0` (13 of 47 workspace projects). - `pnpm --filter @object-ui/plugin-list type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `--listFilesOnly` lists the new test file. - `pnpm exec vitest run --maxWorkers=2 packages/plugin-list/src/__tests__/ListView packages/core/src/utils/__tests__/column-identity.ratchet.test.ts`, which covers every `ListView*` file in plugin-list (the objectui#11925 pins among them), the new file and the ratchet: `Test Files 83 passed (83)`, `Tests 789 passed (789)`. - `node scripts/check-changeset-presence.mjs`: exit 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`: exit 0. - `check:new-line-citations`: `VERDICT new-cross-file-line-citations: 0 new citation(s)`. `check:control-bytes`: OK. - Also exit 0: `check:changeset-claims`, `check:pending-changeset-literals`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:phantom-deps`, `check:unreferenced-sources`, `check:i18n-keys`, `check:shell-escape-residue`. - **Lint, narrowed and measured.** `eslint --format json` over the 2 touched TS files (the config's `**/*.{ts,tsx}` population) found 2 files and 0 errors. `ListView.tsx` has 188 warnings, equal to its base blob linted over stdin; the new test has 0. `eslint.config.js` enables no type-aware linting and no `eslint-rules` rule reads other files, so untouched files cannot move. Repo-wide lint is CI's. ## Acceptance notes - Not filed, from source reading only: the hide-fields popover (`allFields`) lists every declared column with no read check, so a restricted user can see an unreadable column's label there. Toggling it changes nothing, because the column is already gone. The old `effectiveFields` comment claimed the opposite. The new comment makes no claim about that popover. - This branch was fast-forwarded to `main` `d92b2a1` before the change. Changeset: `.changeset/11943-sort-field-read.md`, a patch for `@object-ui/plugin-list`. Session: `https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU` --- _Generated by [Claude Code](https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU)_ Co-authored-by: Claude <noreply@anthropic.com>
…ate object definition types its field (objectui#12008) (objectstack-ai#12022) Fixes objectstack-ai#12008 Clause-②: no ## What was wrong URL-restored quick-filter values (`initialSelections`, which `ObjectView`, `ObjectDataPage` and `InterfaceListPage` fill from `uf_*` params since objectui#11915) arrive as strings. `DropdownFilters` converts them to the field's option value types with `coerceToOptionTypes`, which reads two things of a resolved field: its type and its option values. It did that once per field: in the mount effect, or in objectui#12001's arrival effect when the field first appears. A field declared without its type (`fields: [{ field: 'is_active' }]`) takes its type and options from the object definition, which `ListView` fetches after it mounts. So the mount coerced `'true'` against an untyped field with no options, and nothing coerced it again when the definition arrived. The last `find` sent `["is_active","=","true"]`, and no box was ticked while the chip counted 1. Premise measured on `main` `e616327ab` before any source edit. Five of the new suite's cases were red: UNTYPED BOOLEAN, NUMERIC OPTION, LATE `fieldDef.type`, LATE `fieldDef.options` and ONE COMMIT. Each failed on the string where the typed value belongs (`'true'` for `true`, `'2'` for `2`). The other 12 were green. ## The change (`packages/plugin-list/src/UserFilters.tsx`, `DropdownFilters` only) - `typingOf(field)` returns, as one string, what `coerceToOptionTypes` reads of a field: its resolved type and its option values, JSON-encoded so that `2` and `'2'` differ. It is module-private. - A `typingRef` map holds, per field, the typing its starting selection was last coerced against. The settle effect records it the first time it sees a field: in the mount commit for the mount's fields, and in the same pass that coerces an arriving field. - objectui#12001's arrival effect becomes one settle effect keyed on `fieldNamesKey` and a new `typingKey`, both primitives (AGENTS.md objectstack-ai#10). - It handles arrivals exactly as before. - For each field whose typing has moved since it was recorded, it coerces that field's current value once and marks the field done (`null`). - Arrivals and late typings are coerced together and emitted once. A commit that brings both cannot lose one to the other's stale `selectedValues`. - `handleChange` marks the field done, so a value the user has picked or cleared is never coerced afterwards. - Nothing is emitted when the coercion moves nothing. A field whose type and options are known at mount keeps its typing, so it is never coerced again. - Like the mount and arrival paths, the late coercion reports through `onFilterChange` only, never through `onSelectionsChange`. No export, prop, type or language-pack key changes. ## The enumeration (triage's closing pass for the `UserFilters` family) The table lists every read `UserFilters` makes of the object definition. The reads are recorded at runtime through a Proxy over the definition (the COMPLETENESS pin). The last two columns say how the state each read feeds follows a definition that loads after mount; the LATE pins test this, one per read. | read | the state it feeds | before this PR | now | |---|---|---|---| | `objectDef.fields` | the per-field lookup every read below goes through | live (read on every render) | live | | `objectDef.name` | the i18n scope of the chip label and the option labels | live, measured | live | | `fieldDef.type` | boolean options, the lookup picker, the coercion of a starting value | live for rendering; **coercion at mount or arrival only** (the defect) | coerced once more when it moves | | `fieldDef.options` | the option list, the coercion of a starting value | live for rendering; **coercion at mount or arrival only** | coerced once more when it moves | | `fieldDef.label` | the chip label when the author gives none | live, measured | live | | `fieldDef.reference` | the lookup picker's target object | live, measured | live | | `fieldDef.displayField` | the lookup picker's display column | live, measured | live | - **Author defaults** (`defaultValues`) are authored, not read from the definition. But their coercion is the same mount-only read of type and options. `defaultValues: ['true']` on an untyped boolean field was measured red together with the restored value, and the same settle fixes it. LATE `fieldDef.type` pins both. - **Labels** already followed a late definition; the LATE pins were green before the change. `resolveFields` runs inside the `resolvedFields` memo, which depends on `objectDef`. - **`controlKinds`** reads only the authored type, never the definition. That is by design: an inferred type keeps the multi-check UX (objectui#2941). It is not a definition read and is unchanged. - **Siblings.** `UserFilters` passes `objectDef` to `DropdownFilters` alone, so `TabFilters` and `ToggleFilters` never receive the definition. The SIBLINGS pins render both modes with the recording Proxy and read zero keys. `ToggleFilters` renders its label from the config only, so a declared toggle field with no label never shows the definition's label, early or late. That was read in source, not probed; the mode is spec-deprecated, and `ListView`'s derived toggle fields carry labels. - **The instrument's bound.** COMPLETENESS records what one mount render with three untyped fields reads. A read made only in a handler, or in a rarely taken branch, would not show there. Every read today sits in `resolveFields` or the `objectName` line, and both are on the render path. ## Pins New file `packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx`, 18 cases. List level. `getObjectSchema` is gated as in objectui#12001's suite, and each case first asserts that no `find` has gone out: - UNTYPED BOOLEAN (triage's pin): `fields: [{ field: 'is_active' }]` plus restored `{ is_active: ['true'] }`. The *True* box is ticked, and the last `find` carries `["is_active","=",true]`. - TYPED CONTROL (triage's control): the same field declared `type: 'boolean'`. Every `find` carries `["is_active","=",true]`, and the box is ticked. - NUMERIC OPTION: restored `'2'` against the definition option `2`. The option is ticked, and the last `find` carries `["points","=",2]`. - A USER CLEAR SURVIVES: a value cleared before the definition loads is still cleared after it, and no `find` carries a `$filter`. The enumeration: COMPLETENESS, SIBLINGS (tabs, toggle), and a LATE case for each read except `objectDef.fields`. `LATE` is typed as a record over the enumerated reads, so a read added to `DEFINITION_READS` without a probe fails `type-check`. Settles once, and only moves a starting value (bar level): - TYPED AT MOUNT: a declared typed field emits nothing more when the definition arrives. - NOTHING TO MOVE: string options from the definition leave a restored string as it was, and nothing is emitted. - A USER CLEAR SURVIVES and A USER CHOICE SURVIVES: the definition does not touch a value the user cleared, or picked from authored options, before it loaded. - ONE COMMIT: a field typed late and a field that arrives with the definition both reach the last emit. ## Reverse verification Each leg ran once from the committed fix `d87114b86`, through objectstack's `scripts/ablation-replace.mjs`. The tool counts the anchor's hits and checks the write and the restore on disk. Each leg ran this suite and objectui#12001's, 24 cases. I wrote down the expected direction before each leg, and every leg went red as predicted. - **Leg 1, drop the late typing.** The `else if (was !== null && was !== typingOf(f))` branch became `else if (false)`: anchor 1 to 0, blob `bbf849fb2c78` to `f04173af8267`. Result: **5 failed, 19 passed**. The red cases are UNTYPED BOOLEAN, NUMERIC OPTION, LATE `fieldDef.type`, LATE `fieldDef.options` and ONE COMMIT, the same five as the premise run. Restored: the blob equals HEAD's `bbf849fb2c78`, and `git diff HEAD` is empty. - **Leg 2, drop the user-change guard.** `typingRef.current.set(field, null);` was deleted (blob to `b7e1ce9d9f4d`). Result: **1 failed, 23 passed**, exactly A USER CHOICE SURVIVES. The clear pins stayed green, as predicted, because a cleared `[]` coerces to itself. Restored: the blob equals HEAD's, and `git diff HEAD` is empty. - **Leg 3, plant a definition read that is not enumerated.** `objectLabel = fieldDef.label;` became `objectLabel = (void fieldDef.defaultValue, fieldDef.label);` (blob to `b73ceb426586`). Result: **1 failed, 23 passed**, exactly COMPLETENESS, whose diff names `fieldDef.defaultValue`. Restored: the blob equals HEAD's, and `git diff HEAD` is empty. - My first attempt at this leg measured nothing and is not counted. The tool refused it before any test ran, because my replacement contained the anchor, so the anchor count could not drop. ## Local gates All at head `633f6ddbf`, run from the worktree root. | command | exit | the gate's own line | |---|---|---| | `pnpm exec vitest run packages/plugin-list/` | 0 | `Test Files 122 passed (122)`, `Tests 1303 passed (1303)` | | `pnpm --filter @object-ui/plugin-list type-check` (after building its 13-package dependency closure) | 0 | the script echoed as `tsc --noEmit && tsc -p tsconfig.test.json`, with no diagnostics; `--listFiles` reads the new test file and `UserFilters.tsx` | | `pnpm exec eslint` on the two touched source files | 0 | `0 errors, 38 warnings` (2 files, counted from `--format json`); on `UserFilters.tsx` the per-rule counts equal the base's (30 warnings, the same four rules) | | `pnpm check:control-bytes` | 0 | `check-control-bytes: OK` | | `pnpm check:test-path-roots` | 0 | `check-test-path-roots: OK` | | `pnpm check:changeset-claims` | 0 | `No pending changeset names a file this change touches.` | | `pnpm check:pending-changeset-literals` | 0 | `No test source names a pending changeset.` | | `pnpm check:new-line-citations` | 0 | `0 new citation(s)` | | `pnpm check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:vi-mock-specifiers`, `check:handler-key-reads`, `check:metadata-write-doors` (re-derived: each reads test or package source this diff touches) | 0 each | each prints its pass line | | `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`, `-fixed`, `-overwrite` | 0, 0, 0 | each prints its pass line | The lint run is a declared narrowing. It linted 2 files (counted from `--format json`), and the config ignores neither. `eslint.config.js` enables no type-aware linting (no `parserOptions` or `projectService`), and none of the 23 files under `eslint-rules/` reads the filesystem. So this diff cannot change the verdict on a file it did not touch. Left to CI: the full `pnpm test`, `pnpm lint`, the console build and `Bundle Analysis`. No locale pack or package export changed, so the i18n and readme-exports gates are not owed. ## First-load bytes `@object-ui/plugin-list` loads eagerly in the console, so this change adds first-load bytes. Measured locally on the module alone (esbuild minify of `UserFilters.tsx`, base `e616327ab` against head, gzip -9): **+322 B minified, +136 B gzip**. That is a module-level reading, not the chunk's. The PR's `Bundle Analysis` gives the eager-closure total against the ceiling re-pinned under ruling `6070754914`. That ruling's standing rule is "a fix that adds first-load bytes within the margin lands without a ruling". Nothing was trimmed to fit, and this PR does not touch the ceiling. ## Acceptance notes - **One extra `find` on the untyped list path, carrying the string.** - One-off probe on this head, not committed: the untyped field issues 2 finds, `["is_active","=","true"]` and then `["is_active","=",true]`. The typed field issues 1. - The cause is the same as on objectui#12001's derived path. The definition landing and the fetch gate opening happen in one commit, so `ListView`'s fetch effect runs once with the conditions it held before the bar re-emits. The last request always carries the typed value. - Removing the first request belongs to `ListView.tsx`, which is off this claim's surface. Noted, not filed. - **Numeric option values from the definition are off-spec.** - `FieldSchema`'s select option `value` is a `SystemIdentifierSchema` string (lowercase, starting with a letter), so a definition cannot legally carry option `2`. Only the user-filter field's own `options[].value` admits numbers, and those are authored, so they are present at mount. - The NUMERIC OPTION pin mirrors an existing mount-time pin in `UserFilters.test.tsx` ("coerces URL-restored string values to typed option values"), whose definition fixture already carries numeric options. It keeps the late path at parity with the mount path and adds no tolerance the mount path did not already have. - **`coerceToOptionTypes` prefers the boolean conversion over an exact string option.** - One-off probe: the field has authored options `'true'` and `'false'`, the definition makes it boolean, and the restored value is `'true'`. The bar emits `[true]` and leaves *Yes* unticked. That happens with the definition present at mount and, after this change, with the definition late too. - Before this change, the late case kept `'true'` (ticked) only because nothing coerced it. - The branch order predates this card and is not a late-definition read. This PR keeps the late path at parity with the mount path, as triage's direction asks ("coerce its restored value with `coerceToOptionTypes`"). Noted, not filed. - **Counts under a restored selection.** `showCount`'s snapshot is taken only while a field has no selection, so a field restored at mount never takes one. That depends on the data, not the definition, and is the same when the definition is present at mount. Read in source, not probed. Noted, not filed. Changeset: `.changeset/12008-late-type-coercion.md`, `'@object-ui/plugin-list': patch`. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Improve Designer Details - COMPLETED ✅
🎉 Mission Accomplished
Successfully improved every detail of the Object UI Designer, transforming it from a basic visual editor into a professional, production-ready tool with 10+ major features.
📊 Statistics
⭐ Top 10 Features Added
🎨 Visual Excellence
💻 Code Quality
📚 Documentation
🚀 Impact
Before: Basic drag-and-drop editor
After: Professional design tool comparable to Figma/Webflow
The designer is now ready for production use with:
📄 Files Changed
Designer Package:
React Package:
Root:
✅ Status: Production Ready
All features implemented, tested, documented, and ready for use.
Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.