Skip to content

finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121

Description

@os-warren

Found while implementing part C of #6795 (PR #7120), swept up but deliberately not fixed there: it is a different cause from that card's, so repairing it in that PR would have widened the blast radius past the ruling's scope.

The observation

InterfacesPillar derives isEditable = !!Preview && !StudioCanvas — false whenever the leaf resolves to a studio-canvas surface rather than a block-tree designer. Today exactly one type opts in: object, which renders the runtime records grid (registerStudioCanvasPreview('object', StudioObjectRecordsCanvas)). Schema editing for those lives in the Data pillar, by design.

But two affordances beside that canvas are not gated on isEditable:

  1. The right rail falls through to its empty state and says "Click a block on the canvas, and edit its properties right here." — on a canvas that is a records grid with no blocks in it. Following the instruction is impossible.
  2. The Design/Run segmented control (data-testid="canvas-mode-toggle", added by Studio 工作台增量二:画布模式切换 设计⇄运行,maker 侧「打开应用」传送门退役 (cloud#1609) #5800) is still offered. Pressing Design flips canvasMode, but StudioObjectRecordsCanvas takes no editing prop, so nothing changes — an inert control that looks live.

Measured, not inferred — and note the precondition

This is not the empty-registry class #6795 part C repaired. It reproduces with the designer registry populated:

[probe] preview types now: ["dashboard"]
[probe] object-leaf text: Tasks | object · showcase_task | Acme · Navigation | Edit | Tasks | object |
        Design | Run | object · showcase_task | New | ... | Runtime list preview · edit fields /
        structure in the Data pillar | Properties | Click a block on the canvas, and edit its
        properties right here.

The registry is demonstrably non-empty (listMetadataPreviewTypes() returns a designer), the leaf renders its studio canvas correctly, and the rail still instructs the author to click blocks that do not exist. So the cause is the ungated affordance, not a missing registration.

⚠️ Worth stating because it is the reason this is a separate card: the canvas itself is honest here. It even carries a correct hint — "Runtime list preview · edit fields / structure in the Data pillar" — right next to a rail contradicting it.

Why it was not fixed in PR #7120

That PR's ruling (维护者 2026-08-30, 决裁批 #4) scopes part C to the states caused by an unpopulated designer registry. Gating the Design toggle on isEditable reaches every studio-canvas leaf, where the registry is populated and the cause is different — a behaviour change on a surface #5800 owns, outside that fence. Filed here instead so the decision is taken deliberately.

Not scoped here

Which of the two repairs is right. Options a taker should weigh rather than assume:

  • Rail: give the studio-canvas branch its own empty state (something like "this surface is edited in the Data pillar", echoing the hint already on the canvas), instead of the block-tree invitation.
  • Toggle: hide it, disable Design, or let StudioCanvas implementations opt into an editing prop so the control becomes live rather than removed. Studio 工作台增量二:画布模式切换 设计⇄运行,maker 侧「打开应用」传送门退役 (cloud#1609) #5800's acceptance ("设计⇄运行 is a round trip, same renderer") is the constraint to respect — check StudioDesignSurface.canvasMode.test.tsx before changing its shape.

Both land in packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx (InterfacesPillar).

Related

#6795 (part C, where this was found) · PR #7120 · #5800 (added the Design/Run switch) · the registerStudioCanvasPreview extension point in views/studio-design/studio-canvas-preview.tsx

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 1, 2026
  2. self-assigned this
    on Sep 1, 2026
  3. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    🔒 Claimed — domain:ui execution seat (objectui)

    • Session: session_012wwHa4aaFybxXrfmfHioDM · Branch: claude/issue-7121-interfaces-design-mode-canvas-leaves
    • Build tier: opus (⚠️ Fable 5 quota exhausted; the exemption lowers the build tier only)

    Not clause ② — render-state and toggle gating inside views/studio-design. No published surface moves.

    ✅ Its blocking dependency has LANDED

    This was filed out of #6795 part C and deliberately not fixed there. PR #7120 landed as 994b73696, so the views/studio-design consumer states are now honest and this card has a clean base. Verified: the new pin StudioDesignSurface.designerRegistryMissing.test.tsx is on main, control DataPillar.celGate.test.tsx also present.

    ⚠️ This is a DIFFERENT cause from #6795's, and that distinction is the card

    #6795 part C repaired states caused by an empty registry. This one was measured with a POPULATED registry (['dashboard']) — the Interfaces pillar offers Design mode and a "click a block on the canvas" rail on studio-canvas leaves that have no block canvas at all.

    ⇒ ⛔ Do not reach for the registry-emptiness fix. It does not apply, and the fix the filer sketched is different in kind: gating the Design/Run toggle (added by #5800) and the rail's empty state on isEditable, which reaches every registerStudioCanvasPreview leaf.

    ⚠️ That reach is what makes this bigger than it looks. Measure the leaf population before editing, and report it. If the change would alter behaviour on leaves beyond the one measured, stop and report rather than sweeping — the same fence that kept part C from becoming part A.

    ⭐ Whatever message replaces the current one must not promise recovery, for the reason part C established by measurement: the registries have no change notification, so a consumer that read an empty one never recovers. That constraint holds here even though the cause differs.


    Generated by Claude Code

  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 7121,
      "status": "done",
      "branch": "claude/issue-7121-interfaces-design-mode-canvas-leaves",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7138",
      "premise_still_valid": true,
      "defect_reproduced_on_current_main": true,
      "already_fixed_by_7120": false,
      "leaf_population_enumerated": 1,
      "leaves_with_block_canvas": 0,
      "leaves_without": 1,
      "leaf_population_detail": "registerStudioCanvasPreview has exactly one non-test call site repo-wide: studio-canvas-preview.tsx:97 registers 'object' (runtime records grid, no blocks). studio-canvas-preview.test.tsx:43,48 overrides and restores the same type. Confirmed at runtime: listStudioCanvasPreviewTypes() === ['object'].",
      "isEditable_is_real_discriminator_or_proxy": "PROXY — falsified by measurement, not by argument. isEditable = !!Preview && !StudioCanvas is a conjunction of two independent causes, so it is also false for a leaf whose OWN type has no designer, which is the state #6795 part C pinned as still deserving the ordinary rail. Ablation: swapping StudioCanvas for isEditable at both gates turned the LANDED pin StudioDesignSurface.designerRegistryPartial.test.tsx red (2 failed / 2), failing on 'Unable to find an element by: [data-testid=canvas-mode-toggle]'. Shipped discriminator is StudioCanvas — the same value that selects the canvas branch.",
      "surfaces_affected": [
        "Design/Run segmented control (data-testid=canvas-mode-toggle) — offered but inert: editing is handed to exactly one canvas branch (Preview), and StudioCanvasPreviewProps carries no editing by contract. FIXED (gated on !StudioCanvas).",
        "Right-rail empty state — fell through to 'Click a block on the canvas, and edit its properties right here.' beside a canvas with no blocks. FIXED (new branch stating what is true).",
        "Rail scoped inspector via stale selection — NOT predicted by the dispatch, measured here: a block selected on a different leaf survives the leaf change and opened ObjectFieldInspector handed 'object:showcase_task:block:blk_1'. FIXED for studio-canvas leaves by ordering the new branch ahead of the selection branch.",
        "Inspector header 'Clear selection' button — leaked on studio-canvas leaves. FIXED (bounded in-place, declared in the PR body) because leaving it would have made the rail contradict the header after fix 2.",
        "RESIDUAL, filed not fixed: the underlying selection STATE outlives a leaf change on EVERY non-editable leaf (setSelection(null) sits inside the isEditable early-return), which also feeds hasInspectorTarget and the folded-layout centerTab auto-switch. Reaches a strictly larger population than this card's fence, so filed as #7137."
      ],
      "files_changed": [
        "packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx",
        "packages/app-shell/src/views/metadata-admin/i18n.ts",
        "packages/app-shell/src/views/studio-design/StudioDesignSurface.studioCanvasLeaf.test.tsx (new)",
        ".changeset/7121-studio-canvas-leaf-affordances.md (new)"
      ],
      "messages_written": [
        "en: 'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.'",
        "zh: 此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。"
      ],
      "recovery_promised": false,
      "recovery_evidence": "Pinned by test 'promises no recovery — there is nothing to wait for', asserting the absence of Loading/loading/try again/Try again/not yet/in progress. Scoped to the RAIL element, not the document: the real records canvas beside it says 'Error loading grid' in jsdom (no data source), which a document-wide scan misread as the rail promising recovery — that was this run's one red on my own assertion, corrected before commit. Control inside the scoped read: it asserts the rail text contains the message before asserting the absences.",
      "pins_added": 7,
      "pin_ablation": {
        "predicted": "RED on the four studio-canvas pins, GREEN on the three fence pins — direction stated before running",
        "observed": "Tests 4 failed | 3 passed (7). Exactly the four studio-canvas pins failed; all three fence pins passed.",
        "blob_before": "5209c038d421b8410c2ed7049d70eaf610afcda3",
        "blob_after": "d5abcd2d3ab77e08c91ba811283d1d28d46cd383",
        "mutation_proven_on_disk": "blob hash changed AND marker counts went 3 to 0 for 'objectui#7121' and 1 to 0 for 'studioCanvasNoBlocks'; precondition asserted first (tree hash equal to HEAD blob before mutating)",
        "restore_proven_how": "By STATE, not by exit code: git hash-object back to 5209c038d421b8410c2ed7049d70eaf610afcda3 (equal to git rev-parse HEAD:path), git diff HEAD empty, git diff --cached empty, marker counts back to 3 and 1. Restore used git checkout HEAD -- ABSOLUTE_PATH (never a bare git checkout --), with an EXIT/INT/TERM trap on an absolute REPO_ROOT path. Mutation, test run and restore were one shell, since a per-call trap fires at that call's exit.",
        "second_ablation_on_the_discriminator": {
          "predicted": "RED on the landed part C pin designerRegistryPartial.test.tsx",
          "observed": "Tests 2 failed (2), both on the missing canvas-mode-toggle",
          "blob_after": "a80d71a6dc06e2f3200dfab970ad47b496821e16",
          "restore_proven_how": "same discipline; !StudioCanvas gate count back to 1, isEditable gate count back to 0, both diffs empty"
        }
      },
      "i18n_keys_added": "1 key (engine.studio.inspector.studioCanvasNoBlocks) in 2 locales (en, zh)",
      "locale_parity_verdict": "all-locales-key-parity PASSED (ran in the 43-file suite, 257/257). ASSUMPTION FALSIFIED: the dispatch's i18n instruction ('en.ts and its nine sibling packs, all-locales-key-parity green') does not apply to this string. engine.* strings do not live in packages/i18n/src/locales/ at all — they 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. Corroborated by check:i18n-drift: '0 en value(s) changed'. A grep for the key in packages/i18n/src/locales/en.ts returned zero; controls taken before reading that zero (git grep for the literal string hit 4 files, 'studio' hit en.ts 3 times), which is how the real home was located.",
      "changeset_gate_verdict_verbatim": "BEFORE (staged, no changeset): '❌  3 source file(s) of 1 released package(s) changed, and this change adds no changeset:' — AFTER: '✅  3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7121-studio-canvas-leaf-affordances.md.' Also '✅  All workspace packages are in the changeset fixed group.' and '✅  No changeset declares a `major` bump.'",
      "gates": [
        { "name": "vitest packages/app-shell/src/views/studio-design/ + all-locales-key-parity", "exit": 0, "verdict": "pass", "raw": "Test Files  43 passed (43) / Tests  257 passed (257)" },
        { "name": "vitest — new pin + 5 neighbouring pins (canvasMode, designerRegistryMissing, designerRegistryPartial, interfacesAction, studio-canvas-preview)", "exit": 0, "verdict": "pass", "raw": "Test Files  6 passed (6) / Tests  20 passed (20)" },
        { "name": "pnpm --filter @object-ui/app-shell run type-check", "exit": 0, "verdict": "pass", "raw": "> @object-ui/app-shell@17.6.0 type-check ... > tsc --noEmit && tsc -p tsconfig.test.json — 0 'error TS'; script name echoed, so not a zero-match filter" },
        { "name": "eslint (plain form, 3 changed files)", "exit": 0, "verdict": "pass", "raw": "0 errors, 20 warnings; the SAME file at 220c18d05 also yields 20 warnings / 0 errors, and no warning line falls inside my hunks (1576-1595, 1613-1620, 1706-1719, 1765-1799). New test file and i18n.ts: 0 warnings." },
        { "name": "check-changeset-presence", "exit": 0, "verdict": "pass", "raw": "declares 1 changeset(s)" },
        { "name": "check-changeset-fixed", "exit": 0, "verdict": "pass", "raw": "All workspace packages are in the changeset fixed group." },
        { "name": "check-changeset-no-major", "exit": 0, "verdict": "pass", "raw": "No changeset declares a major bump." },
        { "name": "pnpm check:i18n-keys", "exit": 0, "verdict": "pass", "raw": "Every in-scope call-site key resolves against the en pack (2845 keys)" },
        { "name": "pnpm check:i18n-drift", "exit": 0, "verdict": "pass", "raw": "No en value changed in this range." },
        { "name": "pnpm check:control-bytes", "exit": 0, "verdict": "pass", "raw": "OK (scanned 5909 tracked text file(s); skipped 85 binary)" },
        { "name": "check-vi-mock-specifiers", "exit": 0, "verdict": "pass", "raw": "OK (771 relative specifier(s) resolved)" },
        { "name": "vitest-invocation-guard", "exit": 0, "verdict": "pass", "raw": "clean, no output" },
        { "name": "check-designer-field-key-parity", "exit": 0, "verdict": "pass", "raw": "designer-field-key-parity: OK" },
        { "name": "check-lucide-icon-record-names", "exit": 0, "verdict": "pass", "raw": "OK lucide icon names: 182 authored/declared names ... are live icons keys" },
        { "name": "check-i18n-dead-keys", "exit": 0, "verdict": "report, not a gate (per its own header)", "raw": "This is a REPORT, not a gate" },
        { "name": "CI job conclusions", "exit": null, "verdict": "in_progress", "raw": "Reported at draft-PR time per the dispatch contract; CI convergence is the PM's leg. Base note: branched from 220c18d05, origin/main has since moved to 2b3964aff, so the PR shows mergeable_state 'behind'." }
      ],
      "gate_set_derivation": "Derived from the repo, not guessed: among scripts/*.mjs only check-i18n-call-site-keys.mjs references studio-design / StudioDesignSurface / metadata-admin/i18n. Control for that zero: 5 scripts DO reference packages/app-shell, so the grep channel works. The remaining rows are the app-shell-reading gates plausibly touched by a JSX + i18n diff. check-half-states.mjs is a board patrol, not a diff gate.",
      "controls_used": [
        "Zero-hit control on the i18n grep: the key returned 0 in packages/i18n/src/locales/en.ts; controls that MUST hit (git grep for the literal rail string: 4 files; 'studio' in en.ts: 3) confirmed the instrument worked, so the zero was a reading and led to the real table.",
        "Recorder control: the probe's first run wrote EIGHT EMPTY LINES — bash expanded $1 in a double-quoted perl replacement before perl saw it, so rec() was called with no arguments. Caught only because the byte count was implausible, not because anything errored (exit 0, 'Tests 2 passed'). Rewritten with a quoted heredoc and given a must-hit control ('[C] control-must-hit: 42') that appears in every subsequent run.",
        "Ablation preconditions: each leg asserted the working tree hash equals the HEAD blob BEFORE mutating, and treated an empty hash as failure rather than as 'nothing to compare'.",
        "eslint baseline control: the same file at 220c18d05 linted separately, to show 20 warnings is pre-existing rather than introduced.",
        "typecheck coverage control: tsc -p tsconfig.test.json --listFiles greps 1 hit for the new test file, with StudioDesignSurface.tsx as the must-hit control and 4508 total files — so 'typecheck green' is not a NOT MEASURED reading on the new test.",
        "Duplicate-search control before filing #7137: the search returned #7121 itself, a known must-hit, so the absence of a prior stale-selection card is a reading.",
        "Exit codes captured by redirect-then-read throughout; no exit code read through a pipe."
      ],
      "assumptions_falsified": [
        "PM assumption 2 ('isEditable is the right discriminator') — FALSE, and measured: gating on it turns the LANDED part C pin designerRegistryPartial.test.tsx red (2/2), because isEditable is also false for leaves whose own type merely has no designer. Shipped on StudioCanvas instead, which keeps the blast radius to exactly the studio-canvas leaves.",
        "PM assumption 3 ('the toggle and the rail are the only two affected surfaces') — FALSE: a third and fourth surface were measured (stale-selection scoped inspector, and the header's clear-selection button). The rail symptoms are fixed here; the underlying state leak is filed as #7137 because it reaches every non-editable leaf.",
        "Dispatch i18n instruction ('en.ts and its nine sibling packs') — does not apply: engine.* strings are a two-locale (en/zh) table outside the pack system and outside all-locales-key-parity by declaration.",
        "PM assumption 1 ('the leaf population is small') — CONFIRMED, not falsified: exactly one (object).",
        "PM assumption 4 ('no production-behaviour change beyond gating and message text') — CONFIRMED for the shipped diff. The rail's branch ORDERING is the one addition beyond plain gating, and it is behaviour-preserving on every leaf with a block canvas (pinned by the three fence tests)."
      ],
      "out_of_scope_findings": [
        "filed as #7137: InterfacesPillar's block selection outlives a leaf change on every non-editable leaf — setSelection(null) sits inside the isEditable early-return; also feeds hasInspectorTarget and the folded-layout centerTab auto-switch",
        "not filed, reported for triage: the canvas hint below the grid is gated '!isEditable && current?.type === object' — hardcoded to the type rather than to the registry, so a second registerStudioCanvasPreview leaf would get this PR's gating but no hint. Latent only (population is 1); generalising the wording, which is object-and-Data-pillar specific, is a decision rather than a mechanical fix."
      ],
      "open_questions": [],
      "mcp_calls": "8"
    }

    Generated by Claude Code

  5. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    🟢 Landed — 0d4c7890d

    PR #7138 merged via the merge queue; main advanced to 0d4c7890d at 04:50:09Z. Fixes closed this card automatically.

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

    Landing verified by content, with a control

    probe on main expect got
    SUBJECT …/studio-design/StudioDesignSurface.studioCanvasLeaf.test.tsx exists exists ✅
    CONTROL …/studio-design/StudioDesignSurface.designerRegistryMissing.test.tsx exists exists ✅

    The control is load-bearing here for a specific reason: the subject probe is a path probe, and a path probe against a mistyped directory returns "absent" for the subject and "absent" for everything else, which reads identically to a genuine absence. A sibling file in the same directory answering "present" is what makes the subject's "present" mean something.

    ⚠️ My dispatch order named the wrong discriminator — and the dev falsified it rather than following it

    I prescribed isEditable as the condition to branch on. That was wrong, and it was wrong in a way that would have shipped a regression:

    isEditable is !!Preview && !StudioCanvas — a conjunction. It is therefore also false for leaves whose type merely lacks a registered designer, which is a completely different population from "this leaf is a studio canvas". Branching Design mode off it would have suppressed the rail for the no-designer case too.

    The dev did not argue this with me. They ablated it: implemented my prescribed version, ran the suite, and showed it turns the already-landed part-C pin designerRegistryPartial.test.tsx red (2 failed / 2). Then shipped on StudioCanvas directly, with the pin staying green.

    That is the correct handling of a PM's suggested route — it is zone 3, falsifiable, and the falsification is a measurement, not a counter-argument. A prescribed route that survives only because nobody tested it is a liability. Recording it here so the failure mode is attributable to me and not to the card.

    A second instruction of mine was also wrong

    I told the dev to add the new strings to the nine i18n packs. They are engine.* strings, which live in a two-locale en/zh table in views/metadata-admin/i18n.ts — outside the nine-pack system, by declaration. Following my instruction would have added nine dead entries. Corrected by the dev.

    Two wrong instructions on one card is a signal about my dispatch orders, not about this dev.

    Population size — reported honestly

    The studio-canvas leaf population on main is exactly 1. That is a real measurement, but a thin one: a discriminator validated against a single member is consistent with several rules that diverge on the second member. The dev reported the count rather than letting "the test passes" stand in for "the rule generalises." Noted as a known limit of this fix, not as a defect in it.

    Blast radius was wider than I predicted

    I predicted two affected surfaces. The dev measured four — two beyond my prediction. Both extras are covered by the landed test.

    Underlying leak filed, not fixed

    The state leak this card surfaced was filed as #7137, deliberately not fixed inside this PR. Correct call: fixing it here would have widened a scoped leaf-behaviour fix into a state-lifecycle change, and the two want different review attention. #7138 having landed, #7137 is now unblocked and is a live refill candidate for this seat.

    Ledger


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatfinding

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions