Problem
Studio has two interaction modes, content and layout, and a user has to pick one before doing anything. The mode determines what the canvas selects, what the Inspector shows, what the layers tree lets you click, and which save path runs. In practice a single editing session crosses that line constantly — you fix a heading's copy and then want to wrap it in a link — and today that means stopping to flip a toggle.
Two concrete defects follow from the current design:
- The mode toggle has no permission check at all.
StudioModeSwitch in src/apps/studio/components/StudioHeader.tsx is a two-position MUI Switch rendered unconditionally. Any user who reaches Studio can flip to layout mode and edit view source, regardless of role.
/studio is routed outside the products gate. src/shell/store/products.js:100 only adds "studio" to the product list when user.staff is true, so it does not appear in nav for anyone else — but the route itself is reachable by deep link, so the nav gate is not a security boundary.
There is a permission action intended for exactly this and it is dead code. src/shell/hooks/use-permissions.js:39 has:
case "CODE":
return ["Owner", "Admin", "Developer"].includes(role?.systemRole?.name);
"CODE" appears nowhere else in src/ — the case has zero callers. It also matches on system role name, which misses granular roles entirely.
Proposal
Add a third interaction mode, full, the union of content and layout, and resolve the mode from the user's permissions instead of asking them to pick one. A user with both capabilities lands in full; content-only lands in content; code-only lands in layout. Nobody is offered a mode they cannot act in.
InteractionMode gains "full" host-side only — the bridge stays binary and full maps to layout on the wire.
The mode switch is removed for everyone except Zesty staff (isZestyEmail), who keep it so one account can exercise all three surfaces without re-provisioning roles.
Interaction model
The canvas keeps layout mode's gesture grammar verbatim — mousedown selects, dblclick drills one level. Editing triggers only at the leaf, and drill-down is unchanged. At the leaf the behaviour forks on what the leaf actually is:
- Bound (dynamic) leaf → opens the content editor for that field.
- Static leaf → opens the inline static editor, writing to view source.
Both are the same gesture and should feel identical to the user. The fork must be a positive binding check: a failed static-editability check is not sufficient evidence that a leaf is dynamic, because isLeafStaticallyEditable also fails on client-mutated nodes and diverged paints.
The Inspector stays the hub. A field row in the layers tree opens the Inspector rather than routing to the content editor, because that panel is the only route to Connect Item / Disconnect — routing it away removes the ability to change which field is bound.
Saving
One Save action covering both change types, with a grouped confirmation modal (Content and Layout sections) so a partial failure is legible — if the content write succeeds and the layout write fails, the modal stays open showing only what still needs saving.
Scope
Superseded #4274, which was phase 1 split out when this was planned as three PRs. The Director asked for all phases in one PR, so #4274's criteria ("no new interaction mode is introduced by this PR") became false and are retired with it. Everything below ships together in one PR.
Entitlement
usePermission("CODE") matches role.systemRoleZUID against the role ZUIDs that grant the code product, from a single shared constant so the two lists cannot drift. Not a granularRoles lookup: SystemRole has no code capability (create/delete/grant/publish/read/super/update) and GranularRole is SystemRole & { resourceZUID }, so there is nothing to resolve.
- Studio computes an
availableModes list from the user's content and code capabilities.
- The mode control is hidden entirely except for Zesty staff, who get a
ToggleButtonGroup bounded by availableModes — it can never offer a mode the user lacks permission for.
- A user without code permission cannot reach layout mode by any route, including deep link and page reload.
- Existing two-mode behaviour is otherwise unchanged.
The full mode itself, read and edit
InteractionMode gains "full" and it is the resolved mode when the user's permissions allow both.
- The canvas double-click at a bound leaf opens the content editor; at a static leaf, the inline static editor.
- The Inspector takes capability props (
canEditLayout / canEditContent) instead of branching on a mode string.
- The layers tree takes the union of both modes' behaviour at each of its mode branches.
Merged save
- One Save action with the grouped Content / Layout modal.
- The dirty gate accounts for both change types.
- A forced failure in one section leaves the modal open with only the failed section's rows remaining.
Out of scope
- No backend change. Entitlement is derived from data the client already has.
- Nav placement for non-staff. Whether Studio gets a nav entry outside
user.staff is a product decision and does not block this work. Until it is answered, the feature ships reachable by route but not surfaced in nav for non-staff.
- Whether
layout-only is worth offering. Under the current role map no named role reaches it — Owner/Admin/Developer get both capabilities and SEO/Publisher/Contributor get content without code. It requires a granular role with content update disabled and code access. availableModes computes it either way, so if it is dropped the toggle simply never offers it and the decision is reversible at no cost.
Notes for implementation
- This is a coordinated two-repo change in phase 2. The canvas-side leaf classification lives in the studio bridge, which is injected by the preview engine and not bundled by manager-ui. Sequence the bridge first; the manager-ui side must tolerate a bridge that never sends the new message and fall back to today's behaviour, which is a correct degraded state.
- The mode control change breaks three existing specs that drive the checkbox directly:
cypress/e2e/studio/inspector-panel.spec.js:37, cypress/e2e/studio/studio-wrapper.spec.js:171, cypress/e2e/studio/freestyle-alert.spec.js:68. Keep data-cy="StudioModeToggle" on the group and add per-option hooks.
dismissedAlertModes in StudioWrapper.tsx is mode-scoped and needs a third entry.
- Painting must stay reversible.
updateElementText restores a painted leaf unconditionally before anything else; a new path that opens the content editor from the canvas must not paint outside that discipline, or connect → disconnect → type corrupts saved view code.
- Known gap, phase 2: an element with several addressable text runs loses the Back to Element affordance on the canvas route, because the lone-text-slot resolution deliberately returns null when there is more than one run. The content editor still opens correctly; only the back button is missing. Decide during implementation whether to fall back to the layers row or accept it — it is easy to mistake for a bug in QA.
tsconfig.json has no apps/* path mapping, so cross-app imports in .ts/.tsx must be relative or tsc --noEmit fails while webpack succeeds.
Verification
tsc --noEmit introduces no new errors under src/.
build:dev exits 0.
cypress/e2e/studio/ passes, including the three specs updated for the new toggle.
- New coverage per phase: which toggle options render for a given permission set (phase 1); studio-mode double-click opening the content editor at a bound leaf and the static editor at a static leaf (phase 2); a merged save listing both change types, and a forced content failure keeping the modal open with only the content rows (phase 3).
- Negative assertions need care in this suite:
should("not.exist") guards nothing unless the element is present when it runs. Revert the change and confirm the test fails.
Reconciled with the product PRD (2026-08-14)
- Named
full, not studio — product's word.
- Mode is resolved, not selected — "mode is no longer something the user selects"; the switch is removed except for Zesty staff.
- "One collapsed version" means one save, not one version object. Confirmed with the Director: the intent is one save action committing both content items and code files, which is what ships. No versioning-service work is in scope. Flagged because the PRD's Goals section ("rollback-able as a unit") reads stronger than the intent.
Deliberately not built
The PRD's content-mode UX enhancements are out of scope — this is the mode collapse alone, and mixing them in would make the change harder to review and revert. Verified absent, not assumed:
- Hiding layers/groups with no dynamic elements, and hover-highlighting only dynamic items.
- Hiding the localization switch on single-language instances.
- Inspector value-type prioritisation by selection.
Known consequence
Under the current role map Owner/Admin/Developer all receive both capabilities, so with the switch gone layout-only is unreachable by any named role. It stays implemented at no cost and becomes reachable when the granular Studio permissions this PRD proposes land.
Problem
Studio has two interaction modes,
contentandlayout, and a user has to pick one before doing anything. The mode determines what the canvas selects, what the Inspector shows, what the layers tree lets you click, and which save path runs. In practice a single editing session crosses that line constantly — you fix a heading's copy and then want to wrap it in a link — and today that means stopping to flip a toggle.Two concrete defects follow from the current design:
StudioModeSwitchinsrc/apps/studio/components/StudioHeader.tsxis a two-position MUISwitchrendered unconditionally. Any user who reaches Studio can flip to layout mode and edit view source, regardless of role./studiois routed outside the products gate.src/shell/store/products.js:100only adds"studio"to the product list whenuser.staffis true, so it does not appear in nav for anyone else — but the route itself is reachable by deep link, so the nav gate is not a security boundary.There is a permission action intended for exactly this and it is dead code.
src/shell/hooks/use-permissions.js:39has:"CODE"appears nowhere else insrc/— the case has zero callers. It also matches on system role name, which misses granular roles entirely.Proposal
Add a third interaction mode,
full, the union of content and layout, and resolve the mode from the user's permissions instead of asking them to pick one. A user with both capabilities lands infull; content-only lands incontent; code-only lands inlayout. Nobody is offered a mode they cannot act in.InteractionModegains"full"host-side only — the bridge stays binary andfullmaps tolayouton the wire.The mode switch is removed for everyone except Zesty staff (
isZestyEmail), who keep it so one account can exercise all three surfaces without re-provisioning roles.Interaction model
The canvas keeps layout mode's gesture grammar verbatim —
mousedownselects,dblclickdrills one level. Editing triggers only at the leaf, and drill-down is unchanged. At the leaf the behaviour forks on what the leaf actually is:Both are the same gesture and should feel identical to the user. The fork must be a positive binding check: a failed static-editability check is not sufficient evidence that a leaf is dynamic, because
isLeafStaticallyEditablealso fails on client-mutated nodes and diverged paints.The Inspector stays the hub. A field row in the layers tree opens the Inspector rather than routing to the content editor, because that panel is the only route to Connect Item / Disconnect — routing it away removes the ability to change which field is bound.
Saving
One Save action covering both change types, with a grouped confirmation modal (Content and Layout sections) so a partial failure is legible — if the content write succeeds and the layout write fails, the modal stays open showing only what still needs saving.
Scope
Superseded #4274, which was phase 1 split out when this was planned as three PRs. The Director asked for all phases in one PR, so #4274's criteria ("no new interaction mode is introduced by this PR") became false and are retired with it. Everything below ships together in one PR.
Entitlement
usePermission("CODE")matchesrole.systemRoleZUIDagainst the role ZUIDs that grant thecodeproduct, from a single shared constant so the two lists cannot drift. Not agranularRoleslookup:SystemRolehas no code capability (create/delete/grant/publish/read/super/update) andGranularRoleisSystemRole & { resourceZUID }, so there is nothing to resolve.availableModeslist from the user's content and code capabilities.ToggleButtonGroupbounded byavailableModes— it can never offer a mode the user lacks permission for.The full mode itself, read and edit
InteractionModegains"full"and it is the resolved mode when the user's permissions allow both.canEditLayout/canEditContent) instead of branching on amodestring.Merged save
Out of scope
user.staffis a product decision and does not block this work. Until it is answered, the feature ships reachable by route but not surfaced in nav for non-staff.layout-only is worth offering. Under the current role map no named role reaches it — Owner/Admin/Developer get both capabilities and SEO/Publisher/Contributor get content without code. It requires a granular role with content update disabled and code access.availableModescomputes it either way, so if it is dropped the toggle simply never offers it and the decision is reversible at no cost.Notes for implementation
cypress/e2e/studio/inspector-panel.spec.js:37,cypress/e2e/studio/studio-wrapper.spec.js:171,cypress/e2e/studio/freestyle-alert.spec.js:68. Keepdata-cy="StudioModeToggle"on the group and add per-option hooks.dismissedAlertModesinStudioWrapper.tsxis mode-scoped and needs a third entry.updateElementTextrestores a painted leaf unconditionally before anything else; a new path that opens the content editor from the canvas must not paint outside that discipline, or connect → disconnect → type corrupts saved view code.tsconfig.jsonhas noapps/*path mapping, so cross-app imports in.ts/.tsxmust be relative ortsc --noEmitfails while webpack succeeds.Verification
tsc --noEmitintroduces no new errors undersrc/.build:devexits 0.cypress/e2e/studio/passes, including the three specs updated for the new toggle.should("not.exist")guards nothing unless the element is present when it runs. Revert the change and confirm the test fails.Reconciled with the product PRD (2026-08-14)
full, notstudio— product's word.Deliberately not built
The PRD's content-mode UX enhancements are out of scope — this is the mode collapse alone, and mixing them in would make the change harder to review and revert. Verified absent, not assumed:
Known consequence
Under the current role map Owner/Admin/Developer all receive both capabilities, so with the switch gone
layout-only is unreachable by any named role. It stays implemented at no cost and becomes reachable when the granular Studio permissions this PRD proposes land.