fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves - #7138

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves
Sep 1, 2026
Merged

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves#7138
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7121

The Interfaces pillar offered Design mode and a "Click a block on the canvas, and edit its properties right here." rail beside studio-canvas leaves that have no block canvas at all. Both instructions are impossible to follow there.

Leg 0 — reproduced on current origin/main, NOT already repaired by #7120

Measured on 220c18d05 (which contains #7120 as 994b73696), with the designer registry populated — the card's precondition:

[A] preview types now: ["dashboard"]
[A] studio-canvas types: ["object"]
[A] toggle present: true
[A] rail says click-a-block: true
[A] canvas hint present: true

The canvas beside that rail is honest — it carries "Runtime list preview · edit fields / structure in the Data pillar" — while the rail contradicts it. So this is the ungated affordance, not the empty-registry class #6795 part C repaired.

Leaf population — enumerated before editing

registerStudioCanvasPreview call sites across the whole repo:

sitetypehas a block canvas?
studio-canvas-preview.tsx:97 (built-in default)objectno — runtime records grid
studio-canvas-preview.test.tsx:43,48object (override, restored)test-only

Population: one (object), confirmed at runtime by listStudioCanvasPreviewTypes() === ["object"]. The registry is a public extension point, so the gate is written against the registry, not against the string object.

⚠️isEditable is a proxy, and gating on it would have broken a landed pin

The card sketched gating on isEditable. That is not the right discriminator, and this is measured rather than argued.

isEditable = !!Preview && !StudioCanvas — a conjunction of two independent causes. It is false both for a studio-canvas leaf and for a leaf whose own type simply has no designer. The second is the state #6795 part C pinned as still deserving the ordinary rail, in StudioDesignSurface.designerRegistryPartial.test.tsx.

Ablation, direction predicted before running — swap StudioCanvas for isEditable at both gates, then run that landed pin. Predicted RED, observed RED:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> a80d71a6dc06e2f3200dfab970ad47b496821e16
grep proof -> !StudioCanvas remaining: 0 | isEditable gate: 1
× says no designer is registered for this type, not that none loaded at all
× keeps the ordinary "click a block" empty state when designers ARE registered
TestingLibraryElementError: Unable to find an element by: [data-testid="canvas-mode-toggle"]
Tests 2 failed (2)

Restore proven by state: blob hash back to 5209c038d…, git diff HEAD empty, git diff --cached empty, !StudioCanvas gate count back to 1.

⇒ the gate is StudioCanvas — the same value that selects the canvas branch. Blast radius is exactly the studio-canvas leaves.

The repair

Three surfaces in StudioDesignSurface.tsx (InterfacesPillar), all reached through the shared canvasEl / inspectorBodyEl, so all three layouts — classic, folded-wide, folded-tabs — are covered by one change.

  1. Design/Run switch gated on !StudioCanvas. editing is handed to exactly one canvas branch (Preview); StudioCanvasPreviewProps carries no editing by contract, so the switch moved canvasMode and reached no renderer.
  2. The rail gets a studio-canvas branch stating what the canvas is. It promises no recovery — and here the constraint is stricter than part C's: the statement is not about registration at all, so there is nothing to wait for.
  3. Ordering + the header button — a bounded in-place addition, declared rather than slipped in. The new rail branch is ordered ahead of the selection branch, and the header's "clear selection" button is gated with it. Without this, my own repair would have left the rail saying "no blocks here" beside a button offering to clear a selection.

That third item is a fix for a defect I measured while here:

[D] scoped inspector rendered on studio-canvas leaf: true
[D] inspector was handed type/name: object:showcase_task:block:blk_1

blk_1 is a block on a different leaf's canvas — selection outlives a leaf change because the load effect's setSelection(null) sits inside the isEditable early-return. The rail symptom is closed here; the underlying state leak reaches every non-editable leaf, so it is filed separately as #7137 rather than swept in.

New string

engine.studio.inspector.studioCanvasNoBlocks, added to en and zh.

⚠️ Worth recording: engine.* strings do not live in packages/i18n/src/locales/ and are not covered by all-locales-key-parity. They are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header states the carve-out and which check-i18n-call-site-keys.mjs skips by declaration. check:i18n-drift confirms the packs did not move: "0 en value(s) changed".

Pins

StudioDesignSurface.studioCanvasLeaf.test.tsx — 7 tests. Four pin the studio-canvas leaf; three are the regression fence on a leaf that does have a block canvas, which matters more: the switch is still offered and still round-trips (editing true → false → true, #5800's acceptance), the ordinary "click a block" rail is untouched, and the selection affordances still work.

Ablation, direction predicted before running — revert the source fix, keep the tests. Predicted RED on the four, green on the fence:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> d5abcd2d3ab77e08c91ba811283d1d28d46cd383 (markers now: 0)
× offers no Design/Run switch on a leaf whose canvas cannot read the mode
× replaces the impossible "click a block" invitation with what is true
× promises no recovery — there is nothing to wait for
× does not open a scoped inspector for a block selected on a DIFFERENT leaf
Tests 4 failed | 3 passed (7)

Restore proven by state: blob back to 5209c038d…, git diff HEAD and git diff --cached both empty, markers back to 3 / 1.

Gates — all run on e462194d7, the final commit

gateexitverdict
vitest packages/app-shell/src/views/studio-design/ + locale parity0Test Files 43 passed (43) · Tests 257 passed (257)
pnpm --filter @object-ui/app-shell run type-check0tsc --noEmit && tsc -p tsconfig.test.json, 0 error TS
eslint (plain form, 3 changed files)00 errors, 20 warnings — identical to the 20 on the same file at 220c18d05, none inside my hunks; new test file and i18n.ts contribute 0
check-changeset-presence0✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0✅ All workspace packages are in the changeset fixed group.
check-changeset-no-major0✅ No changeset declares a major bump.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2845 keys)
check:i18n-drift0No en value changed in this range.
check:control-bytes0✅ OK (scanned 5909 tracked text file(s))
check-vi-mock-specifiers0✅ OK (771 relative specifier(s) resolved)
vitest-invocation-guard0clean
check-designer-field-key-parity0designer-field-key-parity: OK
check-lucide-icon-record-names0OK lucide icon names: 182 …

Gate set derived from the repo, not guessed: only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n among scripts/*.mjs (control: 5 scripts do reference packages/app-shell, so that zero is a reading). The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff.

typecheck genuinely covers the new test: tsc -p tsconfig.test.json --listFiles lists studioCanvasLeaf.test.tsx (1 hit; control StudioDesignSurface.tsx also 1; 4508 files total) — so the second tsc invocation is not excluding tests.

Observation, not changed here

The canvas hint below the grid is gated !isEditable && current?.type === 'object' — hardcoded to the type rather than to the registry. A second registerStudioCanvasPreview leaf would get the gating in this PR but no hint, because the hint's wording ("edit fields / structure in the Data pillar") is object-specific and generalising it is a wording decision. Latent only: population is 1 today. Left for triage.

Base

Branched from 220c18d05; origin/main has since moved to 2b3964aff. All measurements above are on e462194d7.


Generated by Claude Code

…leaves
`registerStudioCanvasPreview` opts a type into a canvas that renders the
running app, not an editable draft — `StudioCanvasPreviewProps` carries no
`selection`/`onSelectionChange`/`onPatch`/`editing` by contract. Two
affordances beside such a leaf ignored that: the Design/Run switch was offered
though nothing reads the mode there, and the rail invited the author to click
blocks that cannot exist.
Gated on `StudioCanvas` — the same value that selects the canvas branch — and
deliberately not on `isEditable`, which is a conjunction that would also reach
the no-designer leaves pinned by part C of the earlier registry card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DcwQBEp5.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges. ⚠️behind is expected — you branched from 220c18d05 and main is now 2b3964aff; the queue rebuilds each entry, and I re-run the pre-check before arming.

⭐⭐ My prescribed discriminator would have BROKEN a pin that landed 40 minutes earlier

My ZONE 1 said to gate on isEditable. You did not take it, and you did not merely argue against it — you ablated it: swapping StudioCanvas for isEditable at both gates turns the landed part-C pin StudioDesignSurface.designerRegistryPartial.test.tsxred (2 failed / 2, on the missing canvas-mode-toggle).

The reason is structural: isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose own type merely has no designer — precisely the state #6795 part C pinned as still deserving the ordinary rail. ⇒ My gate would have silently widened the blast radius from "studio-canvas leaves" to "every leaf without a designer", regressing work that landed the same hour.

This is the strongest form of falsification available: not "I think your discriminator is a proxy" but "here is the landed test it turns red." Shipping on StudioCanvas — the same value that selects the canvas branch — keeps the change to exactly the leaves it is about.

⚠️ For my own record: my ZONE 2 asked you to check whether isEditable was a real discriminator or a correlated proxy, and "a proxy that drifts is how this class of defect is born." It was a proxy. I should not have named it in ZONE 1 as the route while asking in ZONE 2 whether it was sound.

I verified the leaf population independently

Exactly one non-test registerStudioCanvasPreview(...) call site on mainstudio-canvas-preview.tsx:97, registering 'object'. The other matches are the function definition, two doc comments, one comment in StudioDesignSurface, and two test overrides. Control (registerMetadataPreview) hits multiple files, so the count is a reading. ⇒ Population 1, as reported, and the "reaches every leaf" worry I raised is bounded.

Two surfaces I did not predict — and the split between fix and file is right

My order named two affected surfaces. You measured four: the toggle, the rail, a stale-selection scoped inspector (a block selected on a different leaf survives the leaf change and opens ObjectFieldInspector on it), and the header's leaked "Clear selection" button.

⭐ And the underlying cause was filed, not fixed: setSelection(null) sits inside the isEditable early-return, so selection outlives a leaf change on every non-editable leaf — a strictly larger population than this card's fence, also feeding hasInspectorTarget and the folded-layout centerTab auto-switch. Filed as #7137 (triaged p2). Fixing the rail symptoms here while routing the state leak is the correct cut: the symptom is in scope, the cause is not.

The clear-selection button is declared as a bounded in-place addition with its reason — leaving it would have made the rail contradict the header after fix 2. Declared rather than absorbed.

⚠️ My i18n instruction was wrong, and you found the real home via a controlled zero

I told you: en.tsand its nine sibling packs, all-locales-key-parity green. That does not apply here.engine.* strings are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header documents the carve-out and which check-i18n-call-site-keys.mjs skips by declaration.

⭐ And the way you established it is the point: the key returned 0 in packages/i18n/src/locales/en.ts, and rather than reading that as "not added yet" you took controls that must hit (the literal rail string → 4 files; studio in en.ts → 3) — which proved the instrument worked and led you to the real table. Corroborated by check:i18n-drift: "0 en value(s) changed."

⭐ The recorder control — the ninth instrument failure this session

Your probe's first run wrote eight empty lines, because bash expanded $1 inside a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Exit 0. Tests 2 passed. Nothing errored.

You caught it because the byte count was implausible, then rewrote it with a quoted heredoc and gave it a must-hit control ([C] control-must-hit: 42) present in every subsequent run. ⇒ That is the correct repair: not "be careful with quoting" but "make the recorder itself carry a control."

⭐ You caught your own false red before committing

The "promises no recovery" test initially scanned the document and read the real records canvas's Error loading grid (jsdom, no data source) as the rail promising recovery. Scoped to the rail element, with a control asserting the rail contains the message before asserting the absences. ⇒ A negative assertion scoped too widely is a false positive waiting to happen, and you found it in your own work.

Scope

Population-1 gating on the real discriminator ✅. No recovery promised, and pinned rather than asserted ✅. Ablation predicted 4 red / 3 green and observed exactly that, with restores proven by state including git diff --cached ✅. eslint baseline compared against the same file at 220c18d05 to show the 20 warnings are pre-existing ✅. Changeset staged-then-run, both verdicts quoted ✅.

⚠️ The unfiled observation you reported — the canvas hint gated on current?.type === 'object' rather than on the registry — is correctly left as triage input: latent at population 1, and generalising object-specific wording is a decision, not a mechanical fix. Noted on the seat's list.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 0d4c789Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7121-interfaces-design-mode-canvas-leaves branch September 1, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves - #7138

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves
Sep 1, 2026
Merged

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves#7138
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7121

The Interfaces pillar offered Design mode and a "Click a block on the canvas, and edit its properties right here." rail beside studio-canvas leaves that have no block canvas at all. Both instructions are impossible to follow there.

Leg 0 — reproduced on current origin/main, NOT already repaired by #7120

Measured on 220c18d05 (which contains #7120 as 994b73696), with the designer registry populated — the card's precondition:

[A] preview types now: ["dashboard"]
[A] studio-canvas types: ["object"]
[A] toggle present: true
[A] rail says click-a-block: true
[A] canvas hint present: true

The canvas beside that rail is honest — it carries "Runtime list preview · edit fields / structure in the Data pillar" — while the rail contradicts it. So this is the ungated affordance, not the empty-registry class #6795 part C repaired.

Leaf population — enumerated before editing

registerStudioCanvasPreview call sites across the whole repo:

sitetypehas a block canvas?
studio-canvas-preview.tsx:97 (built-in default)objectno — runtime records grid
studio-canvas-preview.test.tsx:43,48object (override, restored)test-only

Population: one (object), confirmed at runtime by listStudioCanvasPreviewTypes() === ["object"]. The registry is a public extension point, so the gate is written against the registry, not against the string object.

⚠️isEditable is a proxy, and gating on it would have broken a landed pin

The card sketched gating on isEditable. That is not the right discriminator, and this is measured rather than argued.

isEditable = !!Preview && !StudioCanvas — a conjunction of two independent causes. It is false both for a studio-canvas leaf and for a leaf whose own type simply has no designer. The second is the state #6795 part C pinned as still deserving the ordinary rail, in StudioDesignSurface.designerRegistryPartial.test.tsx.

Ablation, direction predicted before running — swap StudioCanvas for isEditable at both gates, then run that landed pin. Predicted RED, observed RED:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> a80d71a6dc06e2f3200dfab970ad47b496821e16
grep proof -> !StudioCanvas remaining: 0 | isEditable gate: 1
× says no designer is registered for this type, not that none loaded at all
× keeps the ordinary "click a block" empty state when designers ARE registered
TestingLibraryElementError: Unable to find an element by: [data-testid="canvas-mode-toggle"]
Tests 2 failed (2)

Restore proven by state: blob hash back to 5209c038d…, git diff HEAD empty, git diff --cached empty, !StudioCanvas gate count back to 1.

⇒ the gate is StudioCanvas — the same value that selects the canvas branch. Blast radius is exactly the studio-canvas leaves.

The repair

Three surfaces in StudioDesignSurface.tsx (InterfacesPillar), all reached through the shared canvasEl / inspectorBodyEl, so all three layouts — classic, folded-wide, folded-tabs — are covered by one change.

  1. Design/Run switch gated on !StudioCanvas. editing is handed to exactly one canvas branch (Preview); StudioCanvasPreviewProps carries no editing by contract, so the switch moved canvasMode and reached no renderer.
  2. The rail gets a studio-canvas branch stating what the canvas is. It promises no recovery — and here the constraint is stricter than part C's: the statement is not about registration at all, so there is nothing to wait for.
  3. Ordering + the header button — a bounded in-place addition, declared rather than slipped in. The new rail branch is ordered ahead of the selection branch, and the header's "clear selection" button is gated with it. Without this, my own repair would have left the rail saying "no blocks here" beside a button offering to clear a selection.

That third item is a fix for a defect I measured while here:

[D] scoped inspector rendered on studio-canvas leaf: true
[D] inspector was handed type/name: object:showcase_task:block:blk_1

blk_1 is a block on a different leaf's canvas — selection outlives a leaf change because the load effect's setSelection(null) sits inside the isEditable early-return. The rail symptom is closed here; the underlying state leak reaches every non-editable leaf, so it is filed separately as #7137 rather than swept in.

New string

engine.studio.inspector.studioCanvasNoBlocks, added to en and zh.

⚠️ Worth recording: engine.* strings do not live in packages/i18n/src/locales/ and are not covered by all-locales-key-parity. They are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header states the carve-out and which check-i18n-call-site-keys.mjs skips by declaration. check:i18n-drift confirms the packs did not move: "0 en value(s) changed".

Pins

StudioDesignSurface.studioCanvasLeaf.test.tsx — 7 tests. Four pin the studio-canvas leaf; three are the regression fence on a leaf that does have a block canvas, which matters more: the switch is still offered and still round-trips (editing true → false → true, #5800's acceptance), the ordinary "click a block" rail is untouched, and the selection affordances still work.

Ablation, direction predicted before running — revert the source fix, keep the tests. Predicted RED on the four, green on the fence:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> d5abcd2d3ab77e08c91ba811283d1d28d46cd383 (markers now: 0)
× offers no Design/Run switch on a leaf whose canvas cannot read the mode
× replaces the impossible "click a block" invitation with what is true
× promises no recovery — there is nothing to wait for
× does not open a scoped inspector for a block selected on a DIFFERENT leaf
Tests 4 failed | 3 passed (7)

Restore proven by state: blob back to 5209c038d…, git diff HEAD and git diff --cached both empty, markers back to 3 / 1.

Gates — all run on e462194d7, the final commit

gateexitverdict
vitest packages/app-shell/src/views/studio-design/ + locale parity0Test Files 43 passed (43) · Tests 257 passed (257)
pnpm --filter @object-ui/app-shell run type-check0tsc --noEmit && tsc -p tsconfig.test.json, 0 error TS
eslint (plain form, 3 changed files)00 errors, 20 warnings — identical to the 20 on the same file at 220c18d05, none inside my hunks; new test file and i18n.ts contribute 0
check-changeset-presence0✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0✅ All workspace packages are in the changeset fixed group.
check-changeset-no-major0✅ No changeset declares a major bump.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2845 keys)
check:i18n-drift0No en value changed in this range.
check:control-bytes0✅ OK (scanned 5909 tracked text file(s))
check-vi-mock-specifiers0✅ OK (771 relative specifier(s) resolved)
vitest-invocation-guard0clean
check-designer-field-key-parity0designer-field-key-parity: OK
check-lucide-icon-record-names0OK lucide icon names: 182 …

Gate set derived from the repo, not guessed: only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n among scripts/*.mjs (control: 5 scripts do reference packages/app-shell, so that zero is a reading). The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff.

typecheck genuinely covers the new test: tsc -p tsconfig.test.json --listFiles lists studioCanvasLeaf.test.tsx (1 hit; control StudioDesignSurface.tsx also 1; 4508 files total) — so the second tsc invocation is not excluding tests.

Observation, not changed here

The canvas hint below the grid is gated !isEditable && current?.type === 'object' — hardcoded to the type rather than to the registry. A second registerStudioCanvasPreview leaf would get the gating in this PR but no hint, because the hint's wording ("edit fields / structure in the Data pillar") is object-specific and generalising it is a wording decision. Latent only: population is 1 today. Left for triage.

Base

Branched from 220c18d05; origin/main has since moved to 2b3964aff. All measurements above are on e462194d7.


Generated by Claude Code

…leaves
`registerStudioCanvasPreview` opts a type into a canvas that renders the
running app, not an editable draft — `StudioCanvasPreviewProps` carries no
`selection`/`onSelectionChange`/`onPatch`/`editing` by contract. Two
affordances beside such a leaf ignored that: the Design/Run switch was offered
though nothing reads the mode there, and the rail invited the author to click
blocks that cannot exist.
Gated on `StudioCanvas` — the same value that selects the canvas branch — and
deliberately not on `isEditable`, which is a conjunction that would also reach
the no-designer leaves pinned by part C of the earlier registry card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DcwQBEp5.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges. ⚠️behind is expected — you branched from 220c18d05 and main is now 2b3964aff; the queue rebuilds each entry, and I re-run the pre-check before arming.

⭐⭐ My prescribed discriminator would have BROKEN a pin that landed 40 minutes earlier

My ZONE 1 said to gate on isEditable. You did not take it, and you did not merely argue against it — you ablated it: swapping StudioCanvas for isEditable at both gates turns the landed part-C pin StudioDesignSurface.designerRegistryPartial.test.tsxred (2 failed / 2, on the missing canvas-mode-toggle).

The reason is structural: isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose own type merely has no designer — precisely the state #6795 part C pinned as still deserving the ordinary rail. ⇒ My gate would have silently widened the blast radius from "studio-canvas leaves" to "every leaf without a designer", regressing work that landed the same hour.

This is the strongest form of falsification available: not "I think your discriminator is a proxy" but "here is the landed test it turns red." Shipping on StudioCanvas — the same value that selects the canvas branch — keeps the change to exactly the leaves it is about.

⚠️ For my own record: my ZONE 2 asked you to check whether isEditable was a real discriminator or a correlated proxy, and "a proxy that drifts is how this class of defect is born." It was a proxy. I should not have named it in ZONE 1 as the route while asking in ZONE 2 whether it was sound.

I verified the leaf population independently

Exactly one non-test registerStudioCanvasPreview(...) call site on mainstudio-canvas-preview.tsx:97, registering 'object'. The other matches are the function definition, two doc comments, one comment in StudioDesignSurface, and two test overrides. Control (registerMetadataPreview) hits multiple files, so the count is a reading. ⇒ Population 1, as reported, and the "reaches every leaf" worry I raised is bounded.

Two surfaces I did not predict — and the split between fix and file is right

My order named two affected surfaces. You measured four: the toggle, the rail, a stale-selection scoped inspector (a block selected on a different leaf survives the leaf change and opens ObjectFieldInspector on it), and the header's leaked "Clear selection" button.

⭐ And the underlying cause was filed, not fixed: setSelection(null) sits inside the isEditable early-return, so selection outlives a leaf change on every non-editable leaf — a strictly larger population than this card's fence, also feeding hasInspectorTarget and the folded-layout centerTab auto-switch. Filed as #7137 (triaged p2). Fixing the rail symptoms here while routing the state leak is the correct cut: the symptom is in scope, the cause is not.

The clear-selection button is declared as a bounded in-place addition with its reason — leaving it would have made the rail contradict the header after fix 2. Declared rather than absorbed.

⚠️ My i18n instruction was wrong, and you found the real home via a controlled zero

I told you: en.tsand its nine sibling packs, all-locales-key-parity green. That does not apply here.engine.* strings are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header documents the carve-out and which check-i18n-call-site-keys.mjs skips by declaration.

⭐ And the way you established it is the point: the key returned 0 in packages/i18n/src/locales/en.ts, and rather than reading that as "not added yet" you took controls that must hit (the literal rail string → 4 files; studio in en.ts → 3) — which proved the instrument worked and led you to the real table. Corroborated by check:i18n-drift: "0 en value(s) changed."

⭐ The recorder control — the ninth instrument failure this session

Your probe's first run wrote eight empty lines, because bash expanded $1 inside a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Exit 0. Tests 2 passed. Nothing errored.

You caught it because the byte count was implausible, then rewrote it with a quoted heredoc and gave it a must-hit control ([C] control-must-hit: 42) present in every subsequent run. ⇒ That is the correct repair: not "be careful with quoting" but "make the recorder itself carry a control."

⭐ You caught your own false red before committing

The "promises no recovery" test initially scanned the document and read the real records canvas's Error loading grid (jsdom, no data source) as the rail promising recovery. Scoped to the rail element, with a control asserting the rail contains the message before asserting the absences. ⇒ A negative assertion scoped too widely is a false positive waiting to happen, and you found it in your own work.

Scope

Population-1 gating on the real discriminator ✅. No recovery promised, and pinned rather than asserted ✅. Ablation predicted 4 red / 3 green and observed exactly that, with restores proven by state including git diff --cached ✅. eslint baseline compared against the same file at 220c18d05 to show the 20 warnings are pre-existing ✅. Changeset staged-then-run, both verdicts quoted ✅.

⚠️ The unfiled observation you reported — the canvas hint gated on current?.type === 'object' rather than on the registry — is correctly left as triage input: latent at population 1, and generalising object-specific wording is a decision, not a mechanical fix. Noted on the seat's list.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 0d4c789Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7121-interfaces-design-mode-canvas-leaves branch September 1, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves - #7138

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves
Sep 1, 2026
Merged

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves#7138
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7121

The Interfaces pillar offered Design mode and a "Click a block on the canvas, and edit its properties right here." rail beside studio-canvas leaves that have no block canvas at all. Both instructions are impossible to follow there.

Leg 0 — reproduced on current origin/main, NOT already repaired by #7120

Measured on 220c18d05 (which contains #7120 as 994b73696), with the designer registry populated — the card's precondition:

[A] preview types now: ["dashboard"]
[A] studio-canvas types: ["object"]
[A] toggle present: true
[A] rail says click-a-block: true
[A] canvas hint present: true

The canvas beside that rail is honest — it carries "Runtime list preview · edit fields / structure in the Data pillar" — while the rail contradicts it. So this is the ungated affordance, not the empty-registry class #6795 part C repaired.

Leaf population — enumerated before editing

registerStudioCanvasPreview call sites across the whole repo:

sitetypehas a block canvas?
studio-canvas-preview.tsx:97 (built-in default)objectno — runtime records grid
studio-canvas-preview.test.tsx:43,48object (override, restored)test-only

Population: one (object), confirmed at runtime by listStudioCanvasPreviewTypes() === ["object"]. The registry is a public extension point, so the gate is written against the registry, not against the string object.

⚠️isEditable is a proxy, and gating on it would have broken a landed pin

The card sketched gating on isEditable. That is not the right discriminator, and this is measured rather than argued.

isEditable = !!Preview && !StudioCanvas — a conjunction of two independent causes. It is false both for a studio-canvas leaf and for a leaf whose own type simply has no designer. The second is the state #6795 part C pinned as still deserving the ordinary rail, in StudioDesignSurface.designerRegistryPartial.test.tsx.

Ablation, direction predicted before running — swap StudioCanvas for isEditable at both gates, then run that landed pin. Predicted RED, observed RED:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> a80d71a6dc06e2f3200dfab970ad47b496821e16
grep proof -> !StudioCanvas remaining: 0 | isEditable gate: 1
× says no designer is registered for this type, not that none loaded at all
× keeps the ordinary "click a block" empty state when designers ARE registered
TestingLibraryElementError: Unable to find an element by: [data-testid="canvas-mode-toggle"]
Tests 2 failed (2)

Restore proven by state: blob hash back to 5209c038d…, git diff HEAD empty, git diff --cached empty, !StudioCanvas gate count back to 1.

⇒ the gate is StudioCanvas — the same value that selects the canvas branch. Blast radius is exactly the studio-canvas leaves.

The repair

Three surfaces in StudioDesignSurface.tsx (InterfacesPillar), all reached through the shared canvasEl / inspectorBodyEl, so all three layouts — classic, folded-wide, folded-tabs — are covered by one change.

  1. Design/Run switch gated on !StudioCanvas. editing is handed to exactly one canvas branch (Preview); StudioCanvasPreviewProps carries no editing by contract, so the switch moved canvasMode and reached no renderer.
  2. The rail gets a studio-canvas branch stating what the canvas is. It promises no recovery — and here the constraint is stricter than part C's: the statement is not about registration at all, so there is nothing to wait for.
  3. Ordering + the header button — a bounded in-place addition, declared rather than slipped in. The new rail branch is ordered ahead of the selection branch, and the header's "clear selection" button is gated with it. Without this, my own repair would have left the rail saying "no blocks here" beside a button offering to clear a selection.

That third item is a fix for a defect I measured while here:

[D] scoped inspector rendered on studio-canvas leaf: true
[D] inspector was handed type/name: object:showcase_task:block:blk_1

blk_1 is a block on a different leaf's canvas — selection outlives a leaf change because the load effect's setSelection(null) sits inside the isEditable early-return. The rail symptom is closed here; the underlying state leak reaches every non-editable leaf, so it is filed separately as #7137 rather than swept in.

New string

engine.studio.inspector.studioCanvasNoBlocks, added to en and zh.

⚠️ Worth recording: engine.* strings do not live in packages/i18n/src/locales/ and are not covered by all-locales-key-parity. They are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header states the carve-out and which check-i18n-call-site-keys.mjs skips by declaration. check:i18n-drift confirms the packs did not move: "0 en value(s) changed".

Pins

StudioDesignSurface.studioCanvasLeaf.test.tsx — 7 tests. Four pin the studio-canvas leaf; three are the regression fence on a leaf that does have a block canvas, which matters more: the switch is still offered and still round-trips (editing true → false → true, #5800's acceptance), the ordinary "click a block" rail is untouched, and the selection affordances still work.

Ablation, direction predicted before running — revert the source fix, keep the tests. Predicted RED on the four, green on the fence:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> d5abcd2d3ab77e08c91ba811283d1d28d46cd383 (markers now: 0)
× offers no Design/Run switch on a leaf whose canvas cannot read the mode
× replaces the impossible "click a block" invitation with what is true
× promises no recovery — there is nothing to wait for
× does not open a scoped inspector for a block selected on a DIFFERENT leaf
Tests 4 failed | 3 passed (7)

Restore proven by state: blob back to 5209c038d…, git diff HEAD and git diff --cached both empty, markers back to 3 / 1.

Gates — all run on e462194d7, the final commit

gateexitverdict
vitest packages/app-shell/src/views/studio-design/ + locale parity0Test Files 43 passed (43) · Tests 257 passed (257)
pnpm --filter @object-ui/app-shell run type-check0tsc --noEmit && tsc -p tsconfig.test.json, 0 error TS
eslint (plain form, 3 changed files)00 errors, 20 warnings — identical to the 20 on the same file at 220c18d05, none inside my hunks; new test file and i18n.ts contribute 0
check-changeset-presence0✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0✅ All workspace packages are in the changeset fixed group.
check-changeset-no-major0✅ No changeset declares a major bump.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2845 keys)
check:i18n-drift0No en value changed in this range.
check:control-bytes0✅ OK (scanned 5909 tracked text file(s))
check-vi-mock-specifiers0✅ OK (771 relative specifier(s) resolved)
vitest-invocation-guard0clean
check-designer-field-key-parity0designer-field-key-parity: OK
check-lucide-icon-record-names0OK lucide icon names: 182 …

Gate set derived from the repo, not guessed: only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n among scripts/*.mjs (control: 5 scripts do reference packages/app-shell, so that zero is a reading). The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff.

typecheck genuinely covers the new test: tsc -p tsconfig.test.json --listFiles lists studioCanvasLeaf.test.tsx (1 hit; control StudioDesignSurface.tsx also 1; 4508 files total) — so the second tsc invocation is not excluding tests.

Observation, not changed here

The canvas hint below the grid is gated !isEditable && current?.type === 'object' — hardcoded to the type rather than to the registry. A second registerStudioCanvasPreview leaf would get the gating in this PR but no hint, because the hint's wording ("edit fields / structure in the Data pillar") is object-specific and generalising it is a wording decision. Latent only: population is 1 today. Left for triage.

Base

Branched from 220c18d05; origin/main has since moved to 2b3964aff. All measurements above are on e462194d7.


Generated by Claude Code

…leaves
`registerStudioCanvasPreview` opts a type into a canvas that renders the
running app, not an editable draft — `StudioCanvasPreviewProps` carries no
`selection`/`onSelectionChange`/`onPatch`/`editing` by contract. Two
affordances beside such a leaf ignored that: the Design/Run switch was offered
though nothing reads the mode there, and the rail invited the author to click
blocks that cannot exist.
Gated on `StudioCanvas` — the same value that selects the canvas branch — and
deliberately not on `isEditable`, which is a conjunction that would also reach
the no-designer leaves pinned by part C of the earlier registry card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DcwQBEp5.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges. ⚠️behind is expected — you branched from 220c18d05 and main is now 2b3964aff; the queue rebuilds each entry, and I re-run the pre-check before arming.

⭐⭐ My prescribed discriminator would have BROKEN a pin that landed 40 minutes earlier

My ZONE 1 said to gate on isEditable. You did not take it, and you did not merely argue against it — you ablated it: swapping StudioCanvas for isEditable at both gates turns the landed part-C pin StudioDesignSurface.designerRegistryPartial.test.tsxred (2 failed / 2, on the missing canvas-mode-toggle).

The reason is structural: isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose own type merely has no designer — precisely the state #6795 part C pinned as still deserving the ordinary rail. ⇒ My gate would have silently widened the blast radius from "studio-canvas leaves" to "every leaf without a designer", regressing work that landed the same hour.

This is the strongest form of falsification available: not "I think your discriminator is a proxy" but "here is the landed test it turns red." Shipping on StudioCanvas — the same value that selects the canvas branch — keeps the change to exactly the leaves it is about.

⚠️ For my own record: my ZONE 2 asked you to check whether isEditable was a real discriminator or a correlated proxy, and "a proxy that drifts is how this class of defect is born." It was a proxy. I should not have named it in ZONE 1 as the route while asking in ZONE 2 whether it was sound.

I verified the leaf population independently

Exactly one non-test registerStudioCanvasPreview(...) call site on mainstudio-canvas-preview.tsx:97, registering 'object'. The other matches are the function definition, two doc comments, one comment in StudioDesignSurface, and two test overrides. Control (registerMetadataPreview) hits multiple files, so the count is a reading. ⇒ Population 1, as reported, and the "reaches every leaf" worry I raised is bounded.

Two surfaces I did not predict — and the split between fix and file is right

My order named two affected surfaces. You measured four: the toggle, the rail, a stale-selection scoped inspector (a block selected on a different leaf survives the leaf change and opens ObjectFieldInspector on it), and the header's leaked "Clear selection" button.

⭐ And the underlying cause was filed, not fixed: setSelection(null) sits inside the isEditable early-return, so selection outlives a leaf change on every non-editable leaf — a strictly larger population than this card's fence, also feeding hasInspectorTarget and the folded-layout centerTab auto-switch. Filed as #7137 (triaged p2). Fixing the rail symptoms here while routing the state leak is the correct cut: the symptom is in scope, the cause is not.

The clear-selection button is declared as a bounded in-place addition with its reason — leaving it would have made the rail contradict the header after fix 2. Declared rather than absorbed.

⚠️ My i18n instruction was wrong, and you found the real home via a controlled zero

I told you: en.tsand its nine sibling packs, all-locales-key-parity green. That does not apply here.engine.* strings are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header documents the carve-out and which check-i18n-call-site-keys.mjs skips by declaration.

⭐ And the way you established it is the point: the key returned 0 in packages/i18n/src/locales/en.ts, and rather than reading that as "not added yet" you took controls that must hit (the literal rail string → 4 files; studio in en.ts → 3) — which proved the instrument worked and led you to the real table. Corroborated by check:i18n-drift: "0 en value(s) changed."

⭐ The recorder control — the ninth instrument failure this session

Your probe's first run wrote eight empty lines, because bash expanded $1 inside a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Exit 0. Tests 2 passed. Nothing errored.

You caught it because the byte count was implausible, then rewrote it with a quoted heredoc and gave it a must-hit control ([C] control-must-hit: 42) present in every subsequent run. ⇒ That is the correct repair: not "be careful with quoting" but "make the recorder itself carry a control."

⭐ You caught your own false red before committing

The "promises no recovery" test initially scanned the document and read the real records canvas's Error loading grid (jsdom, no data source) as the rail promising recovery. Scoped to the rail element, with a control asserting the rail contains the message before asserting the absences. ⇒ A negative assertion scoped too widely is a false positive waiting to happen, and you found it in your own work.

Scope

Population-1 gating on the real discriminator ✅. No recovery promised, and pinned rather than asserted ✅. Ablation predicted 4 red / 3 green and observed exactly that, with restores proven by state including git diff --cached ✅. eslint baseline compared against the same file at 220c18d05 to show the 20 warnings are pre-existing ✅. Changeset staged-then-run, both verdicts quoted ✅.

⚠️ The unfiled observation you reported — the canvas hint gated on current?.type === 'object' rather than on the registry — is correctly left as triage input: latent at population 1, and generalising object-specific wording is a decision, not a mechanical fix. Noted on the seat's list.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 0d4c789Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7121-interfaces-design-mode-canvas-leaves branch September 1, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves - #7138

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves
Sep 1, 2026
Merged

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves#7138
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7121

The Interfaces pillar offered Design mode and a "Click a block on the canvas, and edit its properties right here." rail beside studio-canvas leaves that have no block canvas at all. Both instructions are impossible to follow there.

Leg 0 — reproduced on current origin/main, NOT already repaired by #7120

Measured on 220c18d05 (which contains #7120 as 994b73696), with the designer registry populated — the card's precondition:

[A] preview types now: ["dashboard"]
[A] studio-canvas types: ["object"]
[A] toggle present: true
[A] rail says click-a-block: true
[A] canvas hint present: true

The canvas beside that rail is honest — it carries "Runtime list preview · edit fields / structure in the Data pillar" — while the rail contradicts it. So this is the ungated affordance, not the empty-registry class #6795 part C repaired.

Leaf population — enumerated before editing

registerStudioCanvasPreview call sites across the whole repo:

sitetypehas a block canvas?
studio-canvas-preview.tsx:97 (built-in default)objectno — runtime records grid
studio-canvas-preview.test.tsx:43,48object (override, restored)test-only

Population: one (object), confirmed at runtime by listStudioCanvasPreviewTypes() === ["object"]. The registry is a public extension point, so the gate is written against the registry, not against the string object.

⚠️isEditable is a proxy, and gating on it would have broken a landed pin

The card sketched gating on isEditable. That is not the right discriminator, and this is measured rather than argued.

isEditable = !!Preview && !StudioCanvas — a conjunction of two independent causes. It is false both for a studio-canvas leaf and for a leaf whose own type simply has no designer. The second is the state #6795 part C pinned as still deserving the ordinary rail, in StudioDesignSurface.designerRegistryPartial.test.tsx.

Ablation, direction predicted before running — swap StudioCanvas for isEditable at both gates, then run that landed pin. Predicted RED, observed RED:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> a80d71a6dc06e2f3200dfab970ad47b496821e16
grep proof -> !StudioCanvas remaining: 0 | isEditable gate: 1
× says no designer is registered for this type, not that none loaded at all
× keeps the ordinary "click a block" empty state when designers ARE registered
TestingLibraryElementError: Unable to find an element by: [data-testid="canvas-mode-toggle"]
Tests 2 failed (2)

Restore proven by state: blob hash back to 5209c038d…, git diff HEAD empty, git diff --cached empty, !StudioCanvas gate count back to 1.

⇒ the gate is StudioCanvas — the same value that selects the canvas branch. Blast radius is exactly the studio-canvas leaves.

The repair

Three surfaces in StudioDesignSurface.tsx (InterfacesPillar), all reached through the shared canvasEl / inspectorBodyEl, so all three layouts — classic, folded-wide, folded-tabs — are covered by one change.

  1. Design/Run switch gated on !StudioCanvas. editing is handed to exactly one canvas branch (Preview); StudioCanvasPreviewProps carries no editing by contract, so the switch moved canvasMode and reached no renderer.
  2. The rail gets a studio-canvas branch stating what the canvas is. It promises no recovery — and here the constraint is stricter than part C's: the statement is not about registration at all, so there is nothing to wait for.
  3. Ordering + the header button — a bounded in-place addition, declared rather than slipped in. The new rail branch is ordered ahead of the selection branch, and the header's "clear selection" button is gated with it. Without this, my own repair would have left the rail saying "no blocks here" beside a button offering to clear a selection.

That third item is a fix for a defect I measured while here:

[D] scoped inspector rendered on studio-canvas leaf: true
[D] inspector was handed type/name: object:showcase_task:block:blk_1

blk_1 is a block on a different leaf's canvas — selection outlives a leaf change because the load effect's setSelection(null) sits inside the isEditable early-return. The rail symptom is closed here; the underlying state leak reaches every non-editable leaf, so it is filed separately as #7137 rather than swept in.

New string

engine.studio.inspector.studioCanvasNoBlocks, added to en and zh.

⚠️ Worth recording: engine.* strings do not live in packages/i18n/src/locales/ and are not covered by all-locales-key-parity. They are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header states the carve-out and which check-i18n-call-site-keys.mjs skips by declaration. check:i18n-drift confirms the packs did not move: "0 en value(s) changed".

Pins

StudioDesignSurface.studioCanvasLeaf.test.tsx — 7 tests. Four pin the studio-canvas leaf; three are the regression fence on a leaf that does have a block canvas, which matters more: the switch is still offered and still round-trips (editing true → false → true, #5800's acceptance), the ordinary "click a block" rail is untouched, and the selection affordances still work.

Ablation, direction predicted before running — revert the source fix, keep the tests. Predicted RED on the four, green on the fence:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> d5abcd2d3ab77e08c91ba811283d1d28d46cd383 (markers now: 0)
× offers no Design/Run switch on a leaf whose canvas cannot read the mode
× replaces the impossible "click a block" invitation with what is true
× promises no recovery — there is nothing to wait for
× does not open a scoped inspector for a block selected on a DIFFERENT leaf
Tests 4 failed | 3 passed (7)

Restore proven by state: blob back to 5209c038d…, git diff HEAD and git diff --cached both empty, markers back to 3 / 1.

Gates — all run on e462194d7, the final commit

gateexitverdict
vitest packages/app-shell/src/views/studio-design/ + locale parity0Test Files 43 passed (43) · Tests 257 passed (257)
pnpm --filter @object-ui/app-shell run type-check0tsc --noEmit && tsc -p tsconfig.test.json, 0 error TS
eslint (plain form, 3 changed files)00 errors, 20 warnings — identical to the 20 on the same file at 220c18d05, none inside my hunks; new test file and i18n.ts contribute 0
check-changeset-presence0✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0✅ All workspace packages are in the changeset fixed group.
check-changeset-no-major0✅ No changeset declares a major bump.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2845 keys)
check:i18n-drift0No en value changed in this range.
check:control-bytes0✅ OK (scanned 5909 tracked text file(s))
check-vi-mock-specifiers0✅ OK (771 relative specifier(s) resolved)
vitest-invocation-guard0clean
check-designer-field-key-parity0designer-field-key-parity: OK
check-lucide-icon-record-names0OK lucide icon names: 182 …

Gate set derived from the repo, not guessed: only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n among scripts/*.mjs (control: 5 scripts do reference packages/app-shell, so that zero is a reading). The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff.

typecheck genuinely covers the new test: tsc -p tsconfig.test.json --listFiles lists studioCanvasLeaf.test.tsx (1 hit; control StudioDesignSurface.tsx also 1; 4508 files total) — so the second tsc invocation is not excluding tests.

Observation, not changed here

The canvas hint below the grid is gated !isEditable && current?.type === 'object' — hardcoded to the type rather than to the registry. A second registerStudioCanvasPreview leaf would get the gating in this PR but no hint, because the hint's wording ("edit fields / structure in the Data pillar") is object-specific and generalising it is a wording decision. Latent only: population is 1 today. Left for triage.

Base

Branched from 220c18d05; origin/main has since moved to 2b3964aff. All measurements above are on e462194d7.


Generated by Claude Code

…leaves
`registerStudioCanvasPreview` opts a type into a canvas that renders the
running app, not an editable draft — `StudioCanvasPreviewProps` carries no
`selection`/`onSelectionChange`/`onPatch`/`editing` by contract. Two
affordances beside such a leaf ignored that: the Design/Run switch was offered
though nothing reads the mode there, and the rail invited the author to click
blocks that cannot exist.
Gated on `StudioCanvas` — the same value that selects the canvas branch — and
deliberately not on `isEditable`, which is a conjunction that would also reach
the no-designer leaves pinned by part C of the earlier registry card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DcwQBEp5.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges. ⚠️behind is expected — you branched from 220c18d05 and main is now 2b3964aff; the queue rebuilds each entry, and I re-run the pre-check before arming.

⭐⭐ My prescribed discriminator would have BROKEN a pin that landed 40 minutes earlier

My ZONE 1 said to gate on isEditable. You did not take it, and you did not merely argue against it — you ablated it: swapping StudioCanvas for isEditable at both gates turns the landed part-C pin StudioDesignSurface.designerRegistryPartial.test.tsxred (2 failed / 2, on the missing canvas-mode-toggle).

The reason is structural: isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose own type merely has no designer — precisely the state #6795 part C pinned as still deserving the ordinary rail. ⇒ My gate would have silently widened the blast radius from "studio-canvas leaves" to "every leaf without a designer", regressing work that landed the same hour.

This is the strongest form of falsification available: not "I think your discriminator is a proxy" but "here is the landed test it turns red." Shipping on StudioCanvas — the same value that selects the canvas branch — keeps the change to exactly the leaves it is about.

⚠️ For my own record: my ZONE 2 asked you to check whether isEditable was a real discriminator or a correlated proxy, and "a proxy that drifts is how this class of defect is born." It was a proxy. I should not have named it in ZONE 1 as the route while asking in ZONE 2 whether it was sound.

I verified the leaf population independently

Exactly one non-test registerStudioCanvasPreview(...) call site on mainstudio-canvas-preview.tsx:97, registering 'object'. The other matches are the function definition, two doc comments, one comment in StudioDesignSurface, and two test overrides. Control (registerMetadataPreview) hits multiple files, so the count is a reading. ⇒ Population 1, as reported, and the "reaches every leaf" worry I raised is bounded.

Two surfaces I did not predict — and the split between fix and file is right

My order named two affected surfaces. You measured four: the toggle, the rail, a stale-selection scoped inspector (a block selected on a different leaf survives the leaf change and opens ObjectFieldInspector on it), and the header's leaked "Clear selection" button.

⭐ And the underlying cause was filed, not fixed: setSelection(null) sits inside the isEditable early-return, so selection outlives a leaf change on every non-editable leaf — a strictly larger population than this card's fence, also feeding hasInspectorTarget and the folded-layout centerTab auto-switch. Filed as #7137 (triaged p2). Fixing the rail symptoms here while routing the state leak is the correct cut: the symptom is in scope, the cause is not.

The clear-selection button is declared as a bounded in-place addition with its reason — leaving it would have made the rail contradict the header after fix 2. Declared rather than absorbed.

⚠️ My i18n instruction was wrong, and you found the real home via a controlled zero

I told you: en.tsand its nine sibling packs, all-locales-key-parity green. That does not apply here.engine.* strings are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header documents the carve-out and which check-i18n-call-site-keys.mjs skips by declaration.

⭐ And the way you established it is the point: the key returned 0 in packages/i18n/src/locales/en.ts, and rather than reading that as "not added yet" you took controls that must hit (the literal rail string → 4 files; studio in en.ts → 3) — which proved the instrument worked and led you to the real table. Corroborated by check:i18n-drift: "0 en value(s) changed."

⭐ The recorder control — the ninth instrument failure this session

Your probe's first run wrote eight empty lines, because bash expanded $1 inside a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Exit 0. Tests 2 passed. Nothing errored.

You caught it because the byte count was implausible, then rewrote it with a quoted heredoc and gave it a must-hit control ([C] control-must-hit: 42) present in every subsequent run. ⇒ That is the correct repair: not "be careful with quoting" but "make the recorder itself carry a control."

⭐ You caught your own false red before committing

The "promises no recovery" test initially scanned the document and read the real records canvas's Error loading grid (jsdom, no data source) as the rail promising recovery. Scoped to the rail element, with a control asserting the rail contains the message before asserting the absences. ⇒ A negative assertion scoped too widely is a false positive waiting to happen, and you found it in your own work.

Scope

Population-1 gating on the real discriminator ✅. No recovery promised, and pinned rather than asserted ✅. Ablation predicted 4 red / 3 green and observed exactly that, with restores proven by state including git diff --cached ✅. eslint baseline compared against the same file at 220c18d05 to show the 20 warnings are pre-existing ✅. Changeset staged-then-run, both verdicts quoted ✅.

⚠️ The unfiled observation you reported — the canvas hint gated on current?.type === 'object' rather than on the registry — is correctly left as triage input: latent at population 1, and generalising object-specific wording is a decision, not a mechanical fix. Noted on the seat's list.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 0d4c789Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7121-interfaces-design-mode-canvas-leaves branch September 1, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves - #7138

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves
Sep 1, 2026
Merged

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves#7138
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7121

The Interfaces pillar offered Design mode and a "Click a block on the canvas, and edit its properties right here." rail beside studio-canvas leaves that have no block canvas at all. Both instructions are impossible to follow there.

Leg 0 — reproduced on current origin/main, NOT already repaired by #7120

Measured on 220c18d05 (which contains #7120 as 994b73696), with the designer registry populated — the card's precondition:

[A] preview types now: ["dashboard"]
[A] studio-canvas types: ["object"]
[A] toggle present: true
[A] rail says click-a-block: true
[A] canvas hint present: true

The canvas beside that rail is honest — it carries "Runtime list preview · edit fields / structure in the Data pillar" — while the rail contradicts it. So this is the ungated affordance, not the empty-registry class #6795 part C repaired.

Leaf population — enumerated before editing

registerStudioCanvasPreview call sites across the whole repo:

sitetypehas a block canvas?
studio-canvas-preview.tsx:97 (built-in default)objectno — runtime records grid
studio-canvas-preview.test.tsx:43,48object (override, restored)test-only

Population: one (object), confirmed at runtime by listStudioCanvasPreviewTypes() === ["object"]. The registry is a public extension point, so the gate is written against the registry, not against the string object.

⚠️isEditable is a proxy, and gating on it would have broken a landed pin

The card sketched gating on isEditable. That is not the right discriminator, and this is measured rather than argued.

isEditable = !!Preview && !StudioCanvas — a conjunction of two independent causes. It is false both for a studio-canvas leaf and for a leaf whose own type simply has no designer. The second is the state #6795 part C pinned as still deserving the ordinary rail, in StudioDesignSurface.designerRegistryPartial.test.tsx.

Ablation, direction predicted before running — swap StudioCanvas for isEditable at both gates, then run that landed pin. Predicted RED, observed RED:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> a80d71a6dc06e2f3200dfab970ad47b496821e16
grep proof -> !StudioCanvas remaining: 0 | isEditable gate: 1
× says no designer is registered for this type, not that none loaded at all
× keeps the ordinary "click a block" empty state when designers ARE registered
TestingLibraryElementError: Unable to find an element by: [data-testid="canvas-mode-toggle"]
Tests 2 failed (2)

Restore proven by state: blob hash back to 5209c038d…, git diff HEAD empty, git diff --cached empty, !StudioCanvas gate count back to 1.

⇒ the gate is StudioCanvas — the same value that selects the canvas branch. Blast radius is exactly the studio-canvas leaves.

The repair

Three surfaces in StudioDesignSurface.tsx (InterfacesPillar), all reached through the shared canvasEl / inspectorBodyEl, so all three layouts — classic, folded-wide, folded-tabs — are covered by one change.

  1. Design/Run switch gated on !StudioCanvas. editing is handed to exactly one canvas branch (Preview); StudioCanvasPreviewProps carries no editing by contract, so the switch moved canvasMode and reached no renderer.
  2. The rail gets a studio-canvas branch stating what the canvas is. It promises no recovery — and here the constraint is stricter than part C's: the statement is not about registration at all, so there is nothing to wait for.
  3. Ordering + the header button — a bounded in-place addition, declared rather than slipped in. The new rail branch is ordered ahead of the selection branch, and the header's "clear selection" button is gated with it. Without this, my own repair would have left the rail saying "no blocks here" beside a button offering to clear a selection.

That third item is a fix for a defect I measured while here:

[D] scoped inspector rendered on studio-canvas leaf: true
[D] inspector was handed type/name: object:showcase_task:block:blk_1

blk_1 is a block on a different leaf's canvas — selection outlives a leaf change because the load effect's setSelection(null) sits inside the isEditable early-return. The rail symptom is closed here; the underlying state leak reaches every non-editable leaf, so it is filed separately as #7137 rather than swept in.

New string

engine.studio.inspector.studioCanvasNoBlocks, added to en and zh.

⚠️ Worth recording: engine.* strings do not live in packages/i18n/src/locales/ and are not covered by all-locales-key-parity. They are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header states the carve-out and which check-i18n-call-site-keys.mjs skips by declaration. check:i18n-drift confirms the packs did not move: "0 en value(s) changed".

Pins

StudioDesignSurface.studioCanvasLeaf.test.tsx — 7 tests. Four pin the studio-canvas leaf; three are the regression fence on a leaf that does have a block canvas, which matters more: the switch is still offered and still round-trips (editing true → false → true, #5800's acceptance), the ordinary "click a block" rail is untouched, and the selection affordances still work.

Ablation, direction predicted before running — revert the source fix, keep the tests. Predicted RED on the four, green on the fence:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> d5abcd2d3ab77e08c91ba811283d1d28d46cd383 (markers now: 0)
× offers no Design/Run switch on a leaf whose canvas cannot read the mode
× replaces the impossible "click a block" invitation with what is true
× promises no recovery — there is nothing to wait for
× does not open a scoped inspector for a block selected on a DIFFERENT leaf
Tests 4 failed | 3 passed (7)

Restore proven by state: blob back to 5209c038d…, git diff HEAD and git diff --cached both empty, markers back to 3 / 1.

Gates — all run on e462194d7, the final commit

gateexitverdict
vitest packages/app-shell/src/views/studio-design/ + locale parity0Test Files 43 passed (43) · Tests 257 passed (257)
pnpm --filter @object-ui/app-shell run type-check0tsc --noEmit && tsc -p tsconfig.test.json, 0 error TS
eslint (plain form, 3 changed files)00 errors, 20 warnings — identical to the 20 on the same file at 220c18d05, none inside my hunks; new test file and i18n.ts contribute 0
check-changeset-presence0✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0✅ All workspace packages are in the changeset fixed group.
check-changeset-no-major0✅ No changeset declares a major bump.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2845 keys)
check:i18n-drift0No en value changed in this range.
check:control-bytes0✅ OK (scanned 5909 tracked text file(s))
check-vi-mock-specifiers0✅ OK (771 relative specifier(s) resolved)
vitest-invocation-guard0clean
check-designer-field-key-parity0designer-field-key-parity: OK
check-lucide-icon-record-names0OK lucide icon names: 182 …

Gate set derived from the repo, not guessed: only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n among scripts/*.mjs (control: 5 scripts do reference packages/app-shell, so that zero is a reading). The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff.

typecheck genuinely covers the new test: tsc -p tsconfig.test.json --listFiles lists studioCanvasLeaf.test.tsx (1 hit; control StudioDesignSurface.tsx also 1; 4508 files total) — so the second tsc invocation is not excluding tests.

Observation, not changed here

The canvas hint below the grid is gated !isEditable && current?.type === 'object' — hardcoded to the type rather than to the registry. A second registerStudioCanvasPreview leaf would get the gating in this PR but no hint, because the hint's wording ("edit fields / structure in the Data pillar") is object-specific and generalising it is a wording decision. Latent only: population is 1 today. Left for triage.

Base

Branched from 220c18d05; origin/main has since moved to 2b3964aff. All measurements above are on e462194d7.


Generated by Claude Code

…leaves
`registerStudioCanvasPreview` opts a type into a canvas that renders the
running app, not an editable draft — `StudioCanvasPreviewProps` carries no
`selection`/`onSelectionChange`/`onPatch`/`editing` by contract. Two
affordances beside such a leaf ignored that: the Design/Run switch was offered
though nothing reads the mode there, and the rail invited the author to click
blocks that cannot exist.
Gated on `StudioCanvas` — the same value that selects the canvas branch — and
deliberately not on `isEditable`, which is a conjunction that would also reach
the no-designer leaves pinned by part C of the earlier registry card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DcwQBEp5.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges. ⚠️behind is expected — you branched from 220c18d05 and main is now 2b3964aff; the queue rebuilds each entry, and I re-run the pre-check before arming.

⭐⭐ My prescribed discriminator would have BROKEN a pin that landed 40 minutes earlier

My ZONE 1 said to gate on isEditable. You did not take it, and you did not merely argue against it — you ablated it: swapping StudioCanvas for isEditable at both gates turns the landed part-C pin StudioDesignSurface.designerRegistryPartial.test.tsxred (2 failed / 2, on the missing canvas-mode-toggle).

The reason is structural: isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose own type merely has no designer — precisely the state #6795 part C pinned as still deserving the ordinary rail. ⇒ My gate would have silently widened the blast radius from "studio-canvas leaves" to "every leaf without a designer", regressing work that landed the same hour.

This is the strongest form of falsification available: not "I think your discriminator is a proxy" but "here is the landed test it turns red." Shipping on StudioCanvas — the same value that selects the canvas branch — keeps the change to exactly the leaves it is about.

⚠️ For my own record: my ZONE 2 asked you to check whether isEditable was a real discriminator or a correlated proxy, and "a proxy that drifts is how this class of defect is born." It was a proxy. I should not have named it in ZONE 1 as the route while asking in ZONE 2 whether it was sound.

I verified the leaf population independently

Exactly one non-test registerStudioCanvasPreview(...) call site on mainstudio-canvas-preview.tsx:97, registering 'object'. The other matches are the function definition, two doc comments, one comment in StudioDesignSurface, and two test overrides. Control (registerMetadataPreview) hits multiple files, so the count is a reading. ⇒ Population 1, as reported, and the "reaches every leaf" worry I raised is bounded.

Two surfaces I did not predict — and the split between fix and file is right

My order named two affected surfaces. You measured four: the toggle, the rail, a stale-selection scoped inspector (a block selected on a different leaf survives the leaf change and opens ObjectFieldInspector on it), and the header's leaked "Clear selection" button.

⭐ And the underlying cause was filed, not fixed: setSelection(null) sits inside the isEditable early-return, so selection outlives a leaf change on every non-editable leaf — a strictly larger population than this card's fence, also feeding hasInspectorTarget and the folded-layout centerTab auto-switch. Filed as #7137 (triaged p2). Fixing the rail symptoms here while routing the state leak is the correct cut: the symptom is in scope, the cause is not.

The clear-selection button is declared as a bounded in-place addition with its reason — leaving it would have made the rail contradict the header after fix 2. Declared rather than absorbed.

⚠️ My i18n instruction was wrong, and you found the real home via a controlled zero

I told you: en.tsand its nine sibling packs, all-locales-key-parity green. That does not apply here.engine.* strings are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header documents the carve-out and which check-i18n-call-site-keys.mjs skips by declaration.

⭐ And the way you established it is the point: the key returned 0 in packages/i18n/src/locales/en.ts, and rather than reading that as "not added yet" you took controls that must hit (the literal rail string → 4 files; studio in en.ts → 3) — which proved the instrument worked and led you to the real table. Corroborated by check:i18n-drift: "0 en value(s) changed."

⭐ The recorder control — the ninth instrument failure this session

Your probe's first run wrote eight empty lines, because bash expanded $1 inside a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Exit 0. Tests 2 passed. Nothing errored.

You caught it because the byte count was implausible, then rewrote it with a quoted heredoc and gave it a must-hit control ([C] control-must-hit: 42) present in every subsequent run. ⇒ That is the correct repair: not "be careful with quoting" but "make the recorder itself carry a control."

⭐ You caught your own false red before committing

The "promises no recovery" test initially scanned the document and read the real records canvas's Error loading grid (jsdom, no data source) as the rail promising recovery. Scoped to the rail element, with a control asserting the rail contains the message before asserting the absences. ⇒ A negative assertion scoped too widely is a false positive waiting to happen, and you found it in your own work.

Scope

Population-1 gating on the real discriminator ✅. No recovery promised, and pinned rather than asserted ✅. Ablation predicted 4 red / 3 green and observed exactly that, with restores proven by state including git diff --cached ✅. eslint baseline compared against the same file at 220c18d05 to show the 20 warnings are pre-existing ✅. Changeset staged-then-run, both verdicts quoted ✅.

⚠️ The unfiled observation you reported — the canvas hint gated on current?.type === 'object' rather than on the registry — is correctly left as triage input: latent at population 1, and generalising object-specific wording is a decision, not a mechanical fix. Noted on the seat's list.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 0d4c789Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7121-interfaces-design-mode-canvas-leaves branch September 1, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves - #7138

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves
Sep 1, 2026
Merged

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves#7138
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7121

The Interfaces pillar offered Design mode and a "Click a block on the canvas, and edit its properties right here." rail beside studio-canvas leaves that have no block canvas at all. Both instructions are impossible to follow there.

Leg 0 — reproduced on current origin/main, NOT already repaired by #7120

Measured on 220c18d05 (which contains #7120 as 994b73696), with the designer registry populated — the card's precondition:

[A] preview types now: ["dashboard"]
[A] studio-canvas types: ["object"]
[A] toggle present: true
[A] rail says click-a-block: true
[A] canvas hint present: true

The canvas beside that rail is honest — it carries "Runtime list preview · edit fields / structure in the Data pillar" — while the rail contradicts it. So this is the ungated affordance, not the empty-registry class #6795 part C repaired.

Leaf population — enumerated before editing

registerStudioCanvasPreview call sites across the whole repo:

sitetypehas a block canvas?
studio-canvas-preview.tsx:97 (built-in default)objectno — runtime records grid
studio-canvas-preview.test.tsx:43,48object (override, restored)test-only

Population: one (object), confirmed at runtime by listStudioCanvasPreviewTypes() === ["object"]. The registry is a public extension point, so the gate is written against the registry, not against the string object.

⚠️isEditable is a proxy, and gating on it would have broken a landed pin

The card sketched gating on isEditable. That is not the right discriminator, and this is measured rather than argued.

isEditable = !!Preview && !StudioCanvas — a conjunction of two independent causes. It is false both for a studio-canvas leaf and for a leaf whose own type simply has no designer. The second is the state #6795 part C pinned as still deserving the ordinary rail, in StudioDesignSurface.designerRegistryPartial.test.tsx.

Ablation, direction predicted before running — swap StudioCanvas for isEditable at both gates, then run that landed pin. Predicted RED, observed RED:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> a80d71a6dc06e2f3200dfab970ad47b496821e16
grep proof -> !StudioCanvas remaining: 0 | isEditable gate: 1
× says no designer is registered for this type, not that none loaded at all
× keeps the ordinary "click a block" empty state when designers ARE registered
TestingLibraryElementError: Unable to find an element by: [data-testid="canvas-mode-toggle"]
Tests 2 failed (2)

Restore proven by state: blob hash back to 5209c038d…, git diff HEAD empty, git diff --cached empty, !StudioCanvas gate count back to 1.

⇒ the gate is StudioCanvas — the same value that selects the canvas branch. Blast radius is exactly the studio-canvas leaves.

The repair

Three surfaces in StudioDesignSurface.tsx (InterfacesPillar), all reached through the shared canvasEl / inspectorBodyEl, so all three layouts — classic, folded-wide, folded-tabs — are covered by one change.

  1. Design/Run switch gated on !StudioCanvas. editing is handed to exactly one canvas branch (Preview); StudioCanvasPreviewProps carries no editing by contract, so the switch moved canvasMode and reached no renderer.
  2. The rail gets a studio-canvas branch stating what the canvas is. It promises no recovery — and here the constraint is stricter than part C's: the statement is not about registration at all, so there is nothing to wait for.
  3. Ordering + the header button — a bounded in-place addition, declared rather than slipped in. The new rail branch is ordered ahead of the selection branch, and the header's "clear selection" button is gated with it. Without this, my own repair would have left the rail saying "no blocks here" beside a button offering to clear a selection.

That third item is a fix for a defect I measured while here:

[D] scoped inspector rendered on studio-canvas leaf: true
[D] inspector was handed type/name: object:showcase_task:block:blk_1

blk_1 is a block on a different leaf's canvas — selection outlives a leaf change because the load effect's setSelection(null) sits inside the isEditable early-return. The rail symptom is closed here; the underlying state leak reaches every non-editable leaf, so it is filed separately as #7137 rather than swept in.

New string

engine.studio.inspector.studioCanvasNoBlocks, added to en and zh.

⚠️ Worth recording: engine.* strings do not live in packages/i18n/src/locales/ and are not covered by all-locales-key-parity. They are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header states the carve-out and which check-i18n-call-site-keys.mjs skips by declaration. check:i18n-drift confirms the packs did not move: "0 en value(s) changed".

Pins

StudioDesignSurface.studioCanvasLeaf.test.tsx — 7 tests. Four pin the studio-canvas leaf; three are the regression fence on a leaf that does have a block canvas, which matters more: the switch is still offered and still round-trips (editing true → false → true, #5800's acceptance), the ordinary "click a block" rail is untouched, and the selection affordances still work.

Ablation, direction predicted before running — revert the source fix, keep the tests. Predicted RED on the four, green on the fence:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> d5abcd2d3ab77e08c91ba811283d1d28d46cd383 (markers now: 0)
× offers no Design/Run switch on a leaf whose canvas cannot read the mode
× replaces the impossible "click a block" invitation with what is true
× promises no recovery — there is nothing to wait for
× does not open a scoped inspector for a block selected on a DIFFERENT leaf
Tests 4 failed | 3 passed (7)

Restore proven by state: blob back to 5209c038d…, git diff HEAD and git diff --cached both empty, markers back to 3 / 1.

Gates — all run on e462194d7, the final commit

gateexitverdict
vitest packages/app-shell/src/views/studio-design/ + locale parity0Test Files 43 passed (43) · Tests 257 passed (257)
pnpm --filter @object-ui/app-shell run type-check0tsc --noEmit && tsc -p tsconfig.test.json, 0 error TS
eslint (plain form, 3 changed files)00 errors, 20 warnings — identical to the 20 on the same file at 220c18d05, none inside my hunks; new test file and i18n.ts contribute 0
check-changeset-presence0✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0✅ All workspace packages are in the changeset fixed group.
check-changeset-no-major0✅ No changeset declares a major bump.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2845 keys)
check:i18n-drift0No en value changed in this range.
check:control-bytes0✅ OK (scanned 5909 tracked text file(s))
check-vi-mock-specifiers0✅ OK (771 relative specifier(s) resolved)
vitest-invocation-guard0clean
check-designer-field-key-parity0designer-field-key-parity: OK
check-lucide-icon-record-names0OK lucide icon names: 182 …

Gate set derived from the repo, not guessed: only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n among scripts/*.mjs (control: 5 scripts do reference packages/app-shell, so that zero is a reading). The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff.

typecheck genuinely covers the new test: tsc -p tsconfig.test.json --listFiles lists studioCanvasLeaf.test.tsx (1 hit; control StudioDesignSurface.tsx also 1; 4508 files total) — so the second tsc invocation is not excluding tests.

Observation, not changed here

The canvas hint below the grid is gated !isEditable && current?.type === 'object' — hardcoded to the type rather than to the registry. A second registerStudioCanvasPreview leaf would get the gating in this PR but no hint, because the hint's wording ("edit fields / structure in the Data pillar") is object-specific and generalising it is a wording decision. Latent only: population is 1 today. Left for triage.

Base

Branched from 220c18d05; origin/main has since moved to 2b3964aff. All measurements above are on e462194d7.


Generated by Claude Code

…leaves
`registerStudioCanvasPreview` opts a type into a canvas that renders the
running app, not an editable draft — `StudioCanvasPreviewProps` carries no
`selection`/`onSelectionChange`/`onPatch`/`editing` by contract. Two
affordances beside such a leaf ignored that: the Design/Run switch was offered
though nothing reads the mode there, and the rail invited the author to click
blocks that cannot exist.
Gated on `StudioCanvas` — the same value that selects the canvas branch — and
deliberately not on `isEditable`, which is a conjunction that would also reach
the no-designer leaves pinned by part C of the earlier registry card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DcwQBEp5.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges. ⚠️behind is expected — you branched from 220c18d05 and main is now 2b3964aff; the queue rebuilds each entry, and I re-run the pre-check before arming.

⭐⭐ My prescribed discriminator would have BROKEN a pin that landed 40 minutes earlier

My ZONE 1 said to gate on isEditable. You did not take it, and you did not merely argue against it — you ablated it: swapping StudioCanvas for isEditable at both gates turns the landed part-C pin StudioDesignSurface.designerRegistryPartial.test.tsxred (2 failed / 2, on the missing canvas-mode-toggle).

The reason is structural: isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose own type merely has no designer — precisely the state #6795 part C pinned as still deserving the ordinary rail. ⇒ My gate would have silently widened the blast radius from "studio-canvas leaves" to "every leaf without a designer", regressing work that landed the same hour.

This is the strongest form of falsification available: not "I think your discriminator is a proxy" but "here is the landed test it turns red." Shipping on StudioCanvas — the same value that selects the canvas branch — keeps the change to exactly the leaves it is about.

⚠️ For my own record: my ZONE 2 asked you to check whether isEditable was a real discriminator or a correlated proxy, and "a proxy that drifts is how this class of defect is born." It was a proxy. I should not have named it in ZONE 1 as the route while asking in ZONE 2 whether it was sound.

I verified the leaf population independently

Exactly one non-test registerStudioCanvasPreview(...) call site on mainstudio-canvas-preview.tsx:97, registering 'object'. The other matches are the function definition, two doc comments, one comment in StudioDesignSurface, and two test overrides. Control (registerMetadataPreview) hits multiple files, so the count is a reading. ⇒ Population 1, as reported, and the "reaches every leaf" worry I raised is bounded.

Two surfaces I did not predict — and the split between fix and file is right

My order named two affected surfaces. You measured four: the toggle, the rail, a stale-selection scoped inspector (a block selected on a different leaf survives the leaf change and opens ObjectFieldInspector on it), and the header's leaked "Clear selection" button.

⭐ And the underlying cause was filed, not fixed: setSelection(null) sits inside the isEditable early-return, so selection outlives a leaf change on every non-editable leaf — a strictly larger population than this card's fence, also feeding hasInspectorTarget and the folded-layout centerTab auto-switch. Filed as #7137 (triaged p2). Fixing the rail symptoms here while routing the state leak is the correct cut: the symptom is in scope, the cause is not.

The clear-selection button is declared as a bounded in-place addition with its reason — leaving it would have made the rail contradict the header after fix 2. Declared rather than absorbed.

⚠️ My i18n instruction was wrong, and you found the real home via a controlled zero

I told you: en.tsand its nine sibling packs, all-locales-key-parity green. That does not apply here.engine.* strings are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header documents the carve-out and which check-i18n-call-site-keys.mjs skips by declaration.

⭐ And the way you established it is the point: the key returned 0 in packages/i18n/src/locales/en.ts, and rather than reading that as "not added yet" you took controls that must hit (the literal rail string → 4 files; studio in en.ts → 3) — which proved the instrument worked and led you to the real table. Corroborated by check:i18n-drift: "0 en value(s) changed."

⭐ The recorder control — the ninth instrument failure this session

Your probe's first run wrote eight empty lines, because bash expanded $1 inside a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Exit 0. Tests 2 passed. Nothing errored.

You caught it because the byte count was implausible, then rewrote it with a quoted heredoc and gave it a must-hit control ([C] control-must-hit: 42) present in every subsequent run. ⇒ That is the correct repair: not "be careful with quoting" but "make the recorder itself carry a control."

⭐ You caught your own false red before committing

The "promises no recovery" test initially scanned the document and read the real records canvas's Error loading grid (jsdom, no data source) as the rail promising recovery. Scoped to the rail element, with a control asserting the rail contains the message before asserting the absences. ⇒ A negative assertion scoped too widely is a false positive waiting to happen, and you found it in your own work.

Scope

Population-1 gating on the real discriminator ✅. No recovery promised, and pinned rather than asserted ✅. Ablation predicted 4 red / 3 green and observed exactly that, with restores proven by state including git diff --cached ✅. eslint baseline compared against the same file at 220c18d05 to show the 20 warnings are pre-existing ✅. Changeset staged-then-run, both verdicts quoted ✅.

⚠️ The unfiled observation you reported — the canvas hint gated on current?.type === 'object' rather than on the registry — is correctly left as triage input: latent at population 1, and generalising object-specific wording is a decision, not a mechanical fix. Noted on the seat's list.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 0d4c789Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7121-interfaces-design-mode-canvas-leaves branch September 1, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves - #7138

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves
Sep 1, 2026
Merged

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves#7138
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7121

The Interfaces pillar offered Design mode and a "Click a block on the canvas, and edit its properties right here." rail beside studio-canvas leaves that have no block canvas at all. Both instructions are impossible to follow there.

Leg 0 — reproduced on current origin/main, NOT already repaired by #7120

Measured on 220c18d05 (which contains #7120 as 994b73696), with the designer registry populated — the card's precondition:

[A] preview types now: ["dashboard"]
[A] studio-canvas types: ["object"]
[A] toggle present: true
[A] rail says click-a-block: true
[A] canvas hint present: true

The canvas beside that rail is honest — it carries "Runtime list preview · edit fields / structure in the Data pillar" — while the rail contradicts it. So this is the ungated affordance, not the empty-registry class #6795 part C repaired.

Leaf population — enumerated before editing

registerStudioCanvasPreview call sites across the whole repo:

sitetypehas a block canvas?
studio-canvas-preview.tsx:97 (built-in default)objectno — runtime records grid
studio-canvas-preview.test.tsx:43,48object (override, restored)test-only

Population: one (object), confirmed at runtime by listStudioCanvasPreviewTypes() === ["object"]. The registry is a public extension point, so the gate is written against the registry, not against the string object.

⚠️isEditable is a proxy, and gating on it would have broken a landed pin

The card sketched gating on isEditable. That is not the right discriminator, and this is measured rather than argued.

isEditable = !!Preview && !StudioCanvas — a conjunction of two independent causes. It is false both for a studio-canvas leaf and for a leaf whose own type simply has no designer. The second is the state #6795 part C pinned as still deserving the ordinary rail, in StudioDesignSurface.designerRegistryPartial.test.tsx.

Ablation, direction predicted before running — swap StudioCanvas for isEditable at both gates, then run that landed pin. Predicted RED, observed RED:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> a80d71a6dc06e2f3200dfab970ad47b496821e16
grep proof -> !StudioCanvas remaining: 0 | isEditable gate: 1
× says no designer is registered for this type, not that none loaded at all
× keeps the ordinary "click a block" empty state when designers ARE registered
TestingLibraryElementError: Unable to find an element by: [data-testid="canvas-mode-toggle"]
Tests 2 failed (2)

Restore proven by state: blob hash back to 5209c038d…, git diff HEAD empty, git diff --cached empty, !StudioCanvas gate count back to 1.

⇒ the gate is StudioCanvas — the same value that selects the canvas branch. Blast radius is exactly the studio-canvas leaves.

The repair

Three surfaces in StudioDesignSurface.tsx (InterfacesPillar), all reached through the shared canvasEl / inspectorBodyEl, so all three layouts — classic, folded-wide, folded-tabs — are covered by one change.

  1. Design/Run switch gated on !StudioCanvas. editing is handed to exactly one canvas branch (Preview); StudioCanvasPreviewProps carries no editing by contract, so the switch moved canvasMode and reached no renderer.
  2. The rail gets a studio-canvas branch stating what the canvas is. It promises no recovery — and here the constraint is stricter than part C's: the statement is not about registration at all, so there is nothing to wait for.
  3. Ordering + the header button — a bounded in-place addition, declared rather than slipped in. The new rail branch is ordered ahead of the selection branch, and the header's "clear selection" button is gated with it. Without this, my own repair would have left the rail saying "no blocks here" beside a button offering to clear a selection.

That third item is a fix for a defect I measured while here:

[D] scoped inspector rendered on studio-canvas leaf: true
[D] inspector was handed type/name: object:showcase_task:block:blk_1

blk_1 is a block on a different leaf's canvas — selection outlives a leaf change because the load effect's setSelection(null) sits inside the isEditable early-return. The rail symptom is closed here; the underlying state leak reaches every non-editable leaf, so it is filed separately as #7137 rather than swept in.

New string

engine.studio.inspector.studioCanvasNoBlocks, added to en and zh.

⚠️ Worth recording: engine.* strings do not live in packages/i18n/src/locales/ and are not covered by all-locales-key-parity. They are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header states the carve-out and which check-i18n-call-site-keys.mjs skips by declaration. check:i18n-drift confirms the packs did not move: "0 en value(s) changed".

Pins

StudioDesignSurface.studioCanvasLeaf.test.tsx — 7 tests. Four pin the studio-canvas leaf; three are the regression fence on a leaf that does have a block canvas, which matters more: the switch is still offered and still round-trips (editing true → false → true, #5800's acceptance), the ordinary "click a block" rail is untouched, and the selection affordances still work.

Ablation, direction predicted before running — revert the source fix, keep the tests. Predicted RED on the four, green on the fence:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> d5abcd2d3ab77e08c91ba811283d1d28d46cd383 (markers now: 0)
× offers no Design/Run switch on a leaf whose canvas cannot read the mode
× replaces the impossible "click a block" invitation with what is true
× promises no recovery — there is nothing to wait for
× does not open a scoped inspector for a block selected on a DIFFERENT leaf
Tests 4 failed | 3 passed (7)

Restore proven by state: blob back to 5209c038d…, git diff HEAD and git diff --cached both empty, markers back to 3 / 1.

Gates — all run on e462194d7, the final commit

gateexitverdict
vitest packages/app-shell/src/views/studio-design/ + locale parity0Test Files 43 passed (43) · Tests 257 passed (257)
pnpm --filter @object-ui/app-shell run type-check0tsc --noEmit && tsc -p tsconfig.test.json, 0 error TS
eslint (plain form, 3 changed files)00 errors, 20 warnings — identical to the 20 on the same file at 220c18d05, none inside my hunks; new test file and i18n.ts contribute 0
check-changeset-presence0✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0✅ All workspace packages are in the changeset fixed group.
check-changeset-no-major0✅ No changeset declares a major bump.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2845 keys)
check:i18n-drift0No en value changed in this range.
check:control-bytes0✅ OK (scanned 5909 tracked text file(s))
check-vi-mock-specifiers0✅ OK (771 relative specifier(s) resolved)
vitest-invocation-guard0clean
check-designer-field-key-parity0designer-field-key-parity: OK
check-lucide-icon-record-names0OK lucide icon names: 182 …

Gate set derived from the repo, not guessed: only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n among scripts/*.mjs (control: 5 scripts do reference packages/app-shell, so that zero is a reading). The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff.

typecheck genuinely covers the new test: tsc -p tsconfig.test.json --listFiles lists studioCanvasLeaf.test.tsx (1 hit; control StudioDesignSurface.tsx also 1; 4508 files total) — so the second tsc invocation is not excluding tests.

Observation, not changed here

The canvas hint below the grid is gated !isEditable && current?.type === 'object' — hardcoded to the type rather than to the registry. A second registerStudioCanvasPreview leaf would get the gating in this PR but no hint, because the hint's wording ("edit fields / structure in the Data pillar") is object-specific and generalising it is a wording decision. Latent only: population is 1 today. Left for triage.

Base

Branched from 220c18d05; origin/main has since moved to 2b3964aff. All measurements above are on e462194d7.


Generated by Claude Code

…leaves
`registerStudioCanvasPreview` opts a type into a canvas that renders the
running app, not an editable draft — `StudioCanvasPreviewProps` carries no
`selection`/`onSelectionChange`/`onPatch`/`editing` by contract. Two
affordances beside such a leaf ignored that: the Design/Run switch was offered
though nothing reads the mode there, and the rail invited the author to click
blocks that cannot exist.
Gated on `StudioCanvas` — the same value that selects the canvas branch — and
deliberately not on `isEditable`, which is a conjunction that would also reach
the no-designer leaves pinned by part C of the earlier registry card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DcwQBEp5.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges. ⚠️behind is expected — you branched from 220c18d05 and main is now 2b3964aff; the queue rebuilds each entry, and I re-run the pre-check before arming.

⭐⭐ My prescribed discriminator would have BROKEN a pin that landed 40 minutes earlier

My ZONE 1 said to gate on isEditable. You did not take it, and you did not merely argue against it — you ablated it: swapping StudioCanvas for isEditable at both gates turns the landed part-C pin StudioDesignSurface.designerRegistryPartial.test.tsxred (2 failed / 2, on the missing canvas-mode-toggle).

The reason is structural: isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose own type merely has no designer — precisely the state #6795 part C pinned as still deserving the ordinary rail. ⇒ My gate would have silently widened the blast radius from "studio-canvas leaves" to "every leaf without a designer", regressing work that landed the same hour.

This is the strongest form of falsification available: not "I think your discriminator is a proxy" but "here is the landed test it turns red." Shipping on StudioCanvas — the same value that selects the canvas branch — keeps the change to exactly the leaves it is about.

⚠️ For my own record: my ZONE 2 asked you to check whether isEditable was a real discriminator or a correlated proxy, and "a proxy that drifts is how this class of defect is born." It was a proxy. I should not have named it in ZONE 1 as the route while asking in ZONE 2 whether it was sound.

I verified the leaf population independently

Exactly one non-test registerStudioCanvasPreview(...) call site on mainstudio-canvas-preview.tsx:97, registering 'object'. The other matches are the function definition, two doc comments, one comment in StudioDesignSurface, and two test overrides. Control (registerMetadataPreview) hits multiple files, so the count is a reading. ⇒ Population 1, as reported, and the "reaches every leaf" worry I raised is bounded.

Two surfaces I did not predict — and the split between fix and file is right

My order named two affected surfaces. You measured four: the toggle, the rail, a stale-selection scoped inspector (a block selected on a different leaf survives the leaf change and opens ObjectFieldInspector on it), and the header's leaked "Clear selection" button.

⭐ And the underlying cause was filed, not fixed: setSelection(null) sits inside the isEditable early-return, so selection outlives a leaf change on every non-editable leaf — a strictly larger population than this card's fence, also feeding hasInspectorTarget and the folded-layout centerTab auto-switch. Filed as #7137 (triaged p2). Fixing the rail symptoms here while routing the state leak is the correct cut: the symptom is in scope, the cause is not.

The clear-selection button is declared as a bounded in-place addition with its reason — leaving it would have made the rail contradict the header after fix 2. Declared rather than absorbed.

⚠️ My i18n instruction was wrong, and you found the real home via a controlled zero

I told you: en.tsand its nine sibling packs, all-locales-key-parity green. That does not apply here.engine.* strings are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header documents the carve-out and which check-i18n-call-site-keys.mjs skips by declaration.

⭐ And the way you established it is the point: the key returned 0 in packages/i18n/src/locales/en.ts, and rather than reading that as "not added yet" you took controls that must hit (the literal rail string → 4 files; studio in en.ts → 3) — which proved the instrument worked and led you to the real table. Corroborated by check:i18n-drift: "0 en value(s) changed."

⭐ The recorder control — the ninth instrument failure this session

Your probe's first run wrote eight empty lines, because bash expanded $1 inside a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Exit 0. Tests 2 passed. Nothing errored.

You caught it because the byte count was implausible, then rewrote it with a quoted heredoc and gave it a must-hit control ([C] control-must-hit: 42) present in every subsequent run. ⇒ That is the correct repair: not "be careful with quoting" but "make the recorder itself carry a control."

⭐ You caught your own false red before committing

The "promises no recovery" test initially scanned the document and read the real records canvas's Error loading grid (jsdom, no data source) as the rail promising recovery. Scoped to the rail element, with a control asserting the rail contains the message before asserting the absences. ⇒ A negative assertion scoped too widely is a false positive waiting to happen, and you found it in your own work.

Scope

Population-1 gating on the real discriminator ✅. No recovery promised, and pinned rather than asserted ✅. Ablation predicted 4 red / 3 green and observed exactly that, with restores proven by state including git diff --cached ✅. eslint baseline compared against the same file at 220c18d05 to show the 20 warnings are pre-existing ✅. Changeset staged-then-run, both verdicts quoted ✅.

⚠️ The unfiled observation you reported — the canvas hint gated on current?.type === 'object' rather than on the registry — is correctly left as triage input: latent at population 1, and generalising object-specific wording is a decision, not a mechanical fix. Noted on the seat's list.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 0d4c789Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7121-interfaces-design-mode-canvas-leaves branch September 1, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves - #7138

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves
Sep 1, 2026
Merged

fix(app-shell): no Design mode or block-canvas rail on studio-canvas leaves#7138
os-warren merged 1 commit into
mainfrom
claude/issue-7121-interfaces-design-mode-canvas-leaves

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7121

The Interfaces pillar offered Design mode and a "Click a block on the canvas, and edit its properties right here." rail beside studio-canvas leaves that have no block canvas at all. Both instructions are impossible to follow there.

Leg 0 — reproduced on current origin/main, NOT already repaired by #7120

Measured on 220c18d05 (which contains #7120 as 994b73696), with the designer registry populated — the card's precondition:

[A] preview types now: ["dashboard"]
[A] studio-canvas types: ["object"]
[A] toggle present: true
[A] rail says click-a-block: true
[A] canvas hint present: true

The canvas beside that rail is honest — it carries "Runtime list preview · edit fields / structure in the Data pillar" — while the rail contradicts it. So this is the ungated affordance, not the empty-registry class #6795 part C repaired.

Leaf population — enumerated before editing

registerStudioCanvasPreview call sites across the whole repo:

sitetypehas a block canvas?
studio-canvas-preview.tsx:97 (built-in default)objectno — runtime records grid
studio-canvas-preview.test.tsx:43,48object (override, restored)test-only

Population: one (object), confirmed at runtime by listStudioCanvasPreviewTypes() === ["object"]. The registry is a public extension point, so the gate is written against the registry, not against the string object.

⚠️isEditable is a proxy, and gating on it would have broken a landed pin

The card sketched gating on isEditable. That is not the right discriminator, and this is measured rather than argued.

isEditable = !!Preview && !StudioCanvas — a conjunction of two independent causes. It is false both for a studio-canvas leaf and for a leaf whose own type simply has no designer. The second is the state #6795 part C pinned as still deserving the ordinary rail, in StudioDesignSurface.designerRegistryPartial.test.tsx.

Ablation, direction predicted before running — swap StudioCanvas for isEditable at both gates, then run that landed pin. Predicted RED, observed RED:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> a80d71a6dc06e2f3200dfab970ad47b496821e16
grep proof -> !StudioCanvas remaining: 0 | isEditable gate: 1
× says no designer is registered for this type, not that none loaded at all
× keeps the ordinary "click a block" empty state when designers ARE registered
TestingLibraryElementError: Unable to find an element by: [data-testid="canvas-mode-toggle"]
Tests 2 failed (2)

Restore proven by state: blob hash back to 5209c038d…, git diff HEAD empty, git diff --cached empty, !StudioCanvas gate count back to 1.

⇒ the gate is StudioCanvas — the same value that selects the canvas branch. Blast radius is exactly the studio-canvas leaves.

The repair

Three surfaces in StudioDesignSurface.tsx (InterfacesPillar), all reached through the shared canvasEl / inspectorBodyEl, so all three layouts — classic, folded-wide, folded-tabs — are covered by one change.

  1. Design/Run switch gated on !StudioCanvas. editing is handed to exactly one canvas branch (Preview); StudioCanvasPreviewProps carries no editing by contract, so the switch moved canvasMode and reached no renderer.
  2. The rail gets a studio-canvas branch stating what the canvas is. It promises no recovery — and here the constraint is stricter than part C's: the statement is not about registration at all, so there is nothing to wait for.
  3. Ordering + the header button — a bounded in-place addition, declared rather than slipped in. The new rail branch is ordered ahead of the selection branch, and the header's "clear selection" button is gated with it. Without this, my own repair would have left the rail saying "no blocks here" beside a button offering to clear a selection.

That third item is a fix for a defect I measured while here:

[D] scoped inspector rendered on studio-canvas leaf: true
[D] inspector was handed type/name: object:showcase_task:block:blk_1

blk_1 is a block on a different leaf's canvas — selection outlives a leaf change because the load effect's setSelection(null) sits inside the isEditable early-return. The rail symptom is closed here; the underlying state leak reaches every non-editable leaf, so it is filed separately as #7137 rather than swept in.

New string

engine.studio.inspector.studioCanvasNoBlocks, added to en and zh.

⚠️ Worth recording: engine.* strings do not live in packages/i18n/src/locales/ and are not covered by all-locales-key-parity. They are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header states the carve-out and which check-i18n-call-site-keys.mjs skips by declaration. check:i18n-drift confirms the packs did not move: "0 en value(s) changed".

Pins

StudioDesignSurface.studioCanvasLeaf.test.tsx — 7 tests. Four pin the studio-canvas leaf; three are the regression fence on a leaf that does have a block canvas, which matters more: the switch is still offered and still round-trips (editing true → false → true, #5800's acceptance), the ordinary "click a block" rail is untouched, and the selection affordances still work.

Ablation, direction predicted before running — revert the source fix, keep the tests. Predicted RED on the four, green on the fence:

MUTATED: 5209c038d421b8410c2ed7049d70eaf610afcda3 -> d5abcd2d3ab77e08c91ba811283d1d28d46cd383 (markers now: 0)
× offers no Design/Run switch on a leaf whose canvas cannot read the mode
× replaces the impossible "click a block" invitation with what is true
× promises no recovery — there is nothing to wait for
× does not open a scoped inspector for a block selected on a DIFFERENT leaf
Tests 4 failed | 3 passed (7)

Restore proven by state: blob back to 5209c038d…, git diff HEAD and git diff --cached both empty, markers back to 3 / 1.

Gates — all run on e462194d7, the final commit

gateexitverdict
vitest packages/app-shell/src/views/studio-design/ + locale parity0Test Files 43 passed (43) · Tests 257 passed (257)
pnpm --filter @object-ui/app-shell run type-check0tsc --noEmit && tsc -p tsconfig.test.json, 0 error TS
eslint (plain form, 3 changed files)00 errors, 20 warnings — identical to the 20 on the same file at 220c18d05, none inside my hunks; new test file and i18n.ts contribute 0
check-changeset-presence0✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0✅ All workspace packages are in the changeset fixed group.
check-changeset-no-major0✅ No changeset declares a major bump.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2845 keys)
check:i18n-drift0No en value changed in this range.
check:control-bytes0✅ OK (scanned 5909 tracked text file(s))
check-vi-mock-specifiers0✅ OK (771 relative specifier(s) resolved)
vitest-invocation-guard0clean
check-designer-field-key-parity0designer-field-key-parity: OK
check-lucide-icon-record-names0OK lucide icon names: 182 …

Gate set derived from the repo, not guessed: only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n among scripts/*.mjs (control: 5 scripts do reference packages/app-shell, so that zero is a reading). The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff.

typecheck genuinely covers the new test: tsc -p tsconfig.test.json --listFiles lists studioCanvasLeaf.test.tsx (1 hit; control StudioDesignSurface.tsx also 1; 4508 files total) — so the second tsc invocation is not excluding tests.

Observation, not changed here

The canvas hint below the grid is gated !isEditable && current?.type === 'object' — hardcoded to the type rather than to the registry. A second registerStudioCanvasPreview leaf would get the gating in this PR but no hint, because the hint's wording ("edit fields / structure in the Data pillar") is object-specific and generalising it is a wording decision. Latent only: population is 1 today. Left for triage.

Base

Branched from 220c18d05; origin/main has since moved to 2b3964aff. All measurements above are on e462194d7.


Generated by Claude Code

…leaves
`registerStudioCanvasPreview` opts a type into a canvas that renders the
running app, not an editable draft — `StudioCanvasPreviewProps` carries no
`selection`/`onSelectionChange`/`onPatch`/`editing` by contract. Two
affordances beside such a leaf ignored that: the Design/Run switch was offered
though nothing reads the mode there, and the rail invited the author to click
blocks that cannot exist.
Gated on `StudioCanvas` — the same value that selects the canvas branch — and
deliberately not on `isEditable`, which is a conjunction that would also reach
the no-designer leaves pinned by part C of the earlier registry card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DcwQBEp5.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges. ⚠️behind is expected — you branched from 220c18d05 and main is now 2b3964aff; the queue rebuilds each entry, and I re-run the pre-check before arming.

⭐⭐ My prescribed discriminator would have BROKEN a pin that landed 40 minutes earlier

My ZONE 1 said to gate on isEditable. You did not take it, and you did not merely argue against it — you ablated it: swapping StudioCanvas for isEditable at both gates turns the landed part-C pin StudioDesignSurface.designerRegistryPartial.test.tsxred (2 failed / 2, on the missing canvas-mode-toggle).

The reason is structural: isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose own type merely has no designer — precisely the state #6795 part C pinned as still deserving the ordinary rail. ⇒ My gate would have silently widened the blast radius from "studio-canvas leaves" to "every leaf without a designer", regressing work that landed the same hour.

This is the strongest form of falsification available: not "I think your discriminator is a proxy" but "here is the landed test it turns red." Shipping on StudioCanvas — the same value that selects the canvas branch — keeps the change to exactly the leaves it is about.

⚠️ For my own record: my ZONE 2 asked you to check whether isEditable was a real discriminator or a correlated proxy, and "a proxy that drifts is how this class of defect is born." It was a proxy. I should not have named it in ZONE 1 as the route while asking in ZONE 2 whether it was sound.

I verified the leaf population independently

Exactly one non-test registerStudioCanvasPreview(...) call site on mainstudio-canvas-preview.tsx:97, registering 'object'. The other matches are the function definition, two doc comments, one comment in StudioDesignSurface, and two test overrides. Control (registerMetadataPreview) hits multiple files, so the count is a reading. ⇒ Population 1, as reported, and the "reaches every leaf" worry I raised is bounded.

Two surfaces I did not predict — and the split between fix and file is right

My order named two affected surfaces. You measured four: the toggle, the rail, a stale-selection scoped inspector (a block selected on a different leaf survives the leaf change and opens ObjectFieldInspector on it), and the header's leaked "Clear selection" button.

⭐ And the underlying cause was filed, not fixed: setSelection(null) sits inside the isEditable early-return, so selection outlives a leaf change on every non-editable leaf — a strictly larger population than this card's fence, also feeding hasInspectorTarget and the folded-layout centerTab auto-switch. Filed as #7137 (triaged p2). Fixing the rail symptoms here while routing the state leak is the correct cut: the symptom is in scope, the cause is not.

The clear-selection button is declared as a bounded in-place addition with its reason — leaving it would have made the rail contradict the header after fix 2. Declared rather than absorbed.

⚠️ My i18n instruction was wrong, and you found the real home via a controlled zero

I told you: en.tsand its nine sibling packs, all-locales-key-parity green. That does not apply here.engine.* strings are a flat dotted-key table with en and zh only, in views/metadata-admin/i18n.ts, whose header documents the carve-out and which check-i18n-call-site-keys.mjs skips by declaration.

⭐ And the way you established it is the point: the key returned 0 in packages/i18n/src/locales/en.ts, and rather than reading that as "not added yet" you took controls that must hit (the literal rail string → 4 files; studio in en.ts → 3) — which proved the instrument worked and led you to the real table. Corroborated by check:i18n-drift: "0 en value(s) changed."

⭐ The recorder control — the ninth instrument failure this session

Your probe's first run wrote eight empty lines, because bash expanded $1 inside a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Exit 0. Tests 2 passed. Nothing errored.

You caught it because the byte count was implausible, then rewrote it with a quoted heredoc and gave it a must-hit control ([C] control-must-hit: 42) present in every subsequent run. ⇒ That is the correct repair: not "be careful with quoting" but "make the recorder itself carry a control."

⭐ You caught your own false red before committing

The "promises no recovery" test initially scanned the document and read the real records canvas's Error loading grid (jsdom, no data source) as the rail promising recovery. Scoped to the rail element, with a control asserting the rail contains the message before asserting the absences. ⇒ A negative assertion scoped too widely is a false positive waiting to happen, and you found it in your own work.

Scope

Population-1 gating on the real discriminator ✅. No recovery promised, and pinned rather than asserted ✅. Ablation predicted 4 red / 3 green and observed exactly that, with restores proven by state including git diff --cached ✅. eslint baseline compared against the same file at 220c18d05 to show the 20 warnings are pre-existing ✅. Changeset staged-then-run, both verdicts quoted ✅.

⚠️ The unfiled observation you reported — the canvas hint gated on current?.type === 'object' rather than on the registry — is correctly left as triage input: latent at population 1, and generalising object-specific wording is a decision, not a mechanical fix. Noted on the seat's list.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 0d4c789Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7121-interfaces-design-mode-canvas-leaves branch September 1, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude